@reventlessdev/reventless-local 3.0.0-alpha.186 → 3.0.0-alpha.187
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 +7 -0
- package/package.json +8 -8
- package/src/Platform.res +3 -0
- package/src/Platform.res.mjs +3 -0
- package/src/adapter/DomainGraphQL_Server.res +8 -34
- package/src/adapter/DomainGraphQL_Server.res.mjs +0 -31
- package/src/adapter/GraphQL_Server.res.mjs +0 -6
- package/src/adapter/LocalObjectStore.res +6 -0
- package/src/adapter/LocalObjectStore.res.mjs +5 -0
- package/src/adapter/LocalUploadResolvers.res +54 -0
- package/src/adapter/LocalUploadResolvers.res.mjs +70 -0
- package/tests/adapter/ServedBucketHttpTest.res +65 -27
- package/tests/adapter/ServedBucketHttpTest.res.mjs +50 -24
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
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.187 (2026-08-01)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **upload:** add the release half of the upload contract via the domain API ([448f887](https://github.com/ReventlessDev/reventless-core/commit/448f88714a5875be420f794a08d482fcf4ba8404))
|
|
11
|
+
|
|
12
|
+
|
|
6
13
|
# 3.0.0-alpha.186 (2026-08-01)
|
|
7
14
|
|
|
8
15
|
### Bug Fixes
|
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.187",
|
|
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
|
"bin": {
|
|
@@ -35,17 +35,17 @@
|
|
|
35
35
|
"sury": "11.0.0-alpha.4",
|
|
36
36
|
"ws": "^8.18.0",
|
|
37
37
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
38
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
39
38
|
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.26",
|
|
39
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
40
40
|
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
|
|
41
41
|
"@reventlessdev/rescript-node": "2.0.0-alpha.0",
|
|
42
42
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
|
|
43
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.
|
|
44
|
-
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.
|
|
45
|
-
"@reventlessdev/reventless-
|
|
46
|
-
"@reventlessdev/reventless-
|
|
47
|
-
"@reventlessdev/reventless-
|
|
48
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
43
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.199",
|
|
44
|
+
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.47",
|
|
45
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.116",
|
|
46
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.63",
|
|
47
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.146",
|
|
48
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.91"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"rescript": "12.3.0",
|
package/src/Platform.res
CHANGED
|
@@ -1901,6 +1901,9 @@ module MakeWithConfig = (
|
|
|
1901
1901
|
~sdlFields=ReventlessCore.GraphQL_Stitcher.relayBaseQueries,
|
|
1902
1902
|
~resolvers=Dict.make(),
|
|
1903
1903
|
)
|
|
1904
|
+
// Upload service (route B) on the domain server — mirrors adding it to
|
|
1905
|
+
// `domainBaseFragment` on AWS.
|
|
1906
|
+
LocalUploadResolvers.register(DomainGraphQL_Server.asInterface)
|
|
1904
1907
|
// In split mode, inject Relay base types (Node interface, PageInfo) into the platform
|
|
1905
1908
|
// server so SDL fragments compile. The node(id) query is Domain-only — the Platform
|
|
1906
1909
|
// API is consumed by admin tools and agents, not Relay clients.
|
package/src/Platform.res.mjs
CHANGED
|
@@ -57,6 +57,7 @@ import * as PgProjectionCatchup$ReventlessLocal from "./adapter/PgProjectionCatc
|
|
|
57
57
|
import * as PluginsReadModelSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs";
|
|
58
58
|
import * as DomainGraphQL_Server$ReventlessLocal from "./adapter/DomainGraphQL_Server.res.mjs";
|
|
59
59
|
import * as EventPublish_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/EventLog/EventPublish_Callback.res.mjs";
|
|
60
|
+
import * as LocalUploadResolvers$ReventlessLocal from "./adapter/LocalUploadResolvers.res.mjs";
|
|
60
61
|
import * as ProjectionCheckpoint$ReventlessLocal from "./adapter/ProjectionCheckpoint.res.mjs";
|
|
61
62
|
import * as ExtensionPointMapping$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/ExtensionPointMapping.res.mjs";
|
|
62
63
|
import * as UiFragments_Projection$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateViewSlice/UiFragments_Projection.res.mjs";
|
|
@@ -1542,6 +1543,7 @@ function MakeWithConfig(Config) {
|
|
|
1542
1543
|
});
|
|
1543
1544
|
DomainGraphQL_Server$ReventlessLocal.registerTypes(GraphQL_Stitcher$ReventlessCore.relayBaseTypes);
|
|
1544
1545
|
DomainGraphQL_Server$ReventlessLocal.registerQueries(GraphQL_Stitcher$ReventlessCore.relayBaseQueries, {});
|
|
1546
|
+
LocalUploadResolvers$ReventlessLocal.register(DomainGraphQL_Server$ReventlessLocal.asInterface);
|
|
1545
1547
|
if (Config.splitApi) {
|
|
1546
1548
|
PlatformGraphQL_Server$ReventlessLocal.registerTypes(GraphQL_Stitcher$ReventlessCore.relayBaseTypes);
|
|
1547
1549
|
}
|
|
@@ -3212,6 +3214,7 @@ function Make($star) {
|
|
|
3212
3214
|
});
|
|
3213
3215
|
DomainGraphQL_Server$ReventlessLocal.registerTypes(GraphQL_Stitcher$ReventlessCore.relayBaseTypes);
|
|
3214
3216
|
DomainGraphQL_Server$ReventlessLocal.registerQueries(GraphQL_Stitcher$ReventlessCore.relayBaseQueries, {});
|
|
3217
|
+
LocalUploadResolvers$ReventlessLocal.register(DomainGraphQL_Server$ReventlessLocal.asInterface);
|
|
3215
3218
|
PlatformGraphQL_Server$ReventlessLocal.registerTypes(GraphQL_Stitcher$ReventlessCore.relayBaseTypes);
|
|
3216
3219
|
currentDeployTarget.contents = "Domain";
|
|
3217
3220
|
};
|
|
@@ -126,15 +126,14 @@ let handleLogout = (_req: nodeRequest, res: nodeResponse): unit => {
|
|
|
126
126
|
|
|
127
127
|
// -- Served-bucket routes (local analogue of the AWS CloudFront read path) ----
|
|
128
128
|
//
|
|
129
|
-
// The dev platform has no object bucket, so these
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
let uploadPresignPath = "/__inmemory/upload"
|
|
129
|
+
// The dev platform has no object bucket, so these routes back the same UI contract
|
|
130
|
+
// locally against `LocalObjectStore`:
|
|
131
|
+
// PUT /{prefix}/{key} raw bytes → 200 (store)
|
|
132
|
+
// GET /{prefix}/{key} → the stored bytes
|
|
133
|
+
// Minting (`Upload_Presign`) and release (`Upload_Release`) are platform-API
|
|
134
|
+
// mutations under route B (see [docs/plans/upload-release-path.md], resolved in
|
|
135
|
+
// Platform.res against this same `LocalObjectStore`); the presign returns
|
|
136
|
+
// `uploadUrl == storageRef == /{prefix}/{key}`, so the byte PUT below is unchanged.
|
|
138
137
|
|
|
139
138
|
// CORS for the browser PUT (a cross-origin preflight when the dev origin isn't
|
|
140
139
|
// same-origin with the API); the seed uploads from Node and needs none.
|
|
@@ -144,29 +143,6 @@ let _corsWriteHeaders = {
|
|
|
144
143
|
"Access-Control-Allow-Headers": "*",
|
|
145
144
|
}
|
|
146
145
|
|
|
147
|
-
// Presign-shaped: mint a fresh uuid key under the served prefix and hand back a
|
|
148
|
-
// same-origin `/{prefix}/{key}` for both the PUT target and the stored ref.
|
|
149
|
-
let handleUploadPresign = (req: nodeRequest, res: nodeResponse): unit =>
|
|
150
|
-
readBody(req, body => {
|
|
151
|
-
let parsed =
|
|
152
|
-
try body->JSON.parseOrThrow->JSON.Decode.object->Option.getOr(Dict.make()) catch {
|
|
153
|
-
| _ => Dict.make()
|
|
154
|
-
}
|
|
155
|
-
let fileName =
|
|
156
|
-
parsed->Dict.get("fileName")->Option.flatMap(JSON.Decode.string)->Option.getOr("upload")
|
|
157
|
-
let ref = `/${LocalObjectStore.defaultUploadPrefix}/${NodeCrypto.randomUUID()}/${fileName}`
|
|
158
|
-
_writeJson(
|
|
159
|
-
res,
|
|
160
|
-
~status=200,
|
|
161
|
-
JSON.Encode.object(
|
|
162
|
-
Dict.fromArray([
|
|
163
|
-
("uploadUrl", JSON.Encode.string(ref)),
|
|
164
|
-
("storageRef", JSON.Encode.string(ref)),
|
|
165
|
-
]),
|
|
166
|
-
),
|
|
167
|
-
)
|
|
168
|
-
})
|
|
169
|
-
|
|
170
146
|
let handleObjectPut = (req: nodeRequest, res: nodeResponse, ~key: string): unit => {
|
|
171
147
|
let contentType =
|
|
172
148
|
req.headers->Dict.get("content-type")->Option.getOr("application/octet-stream")
|
|
@@ -231,8 +207,6 @@ let _dispatch = (req: nodeRequest, res: nodeResponse, yoga: YG.yoga, getSdl: uni
|
|
|
231
207
|
handleLogin(req, res)
|
|
232
208
|
} else if path == "/__inmemory/logout" && req.method == "POST" {
|
|
233
209
|
handleLogout(req, res)
|
|
234
|
-
} else if path == uploadPresignPath && req.method == "POST" {
|
|
235
|
-
handleUploadPresign(req, res)
|
|
236
210
|
} else if path == "/events" && req.method == "POST" {
|
|
237
211
|
// Local AppSync Events publish endpoint (`/client/**` only). The
|
|
238
212
|
// Authorization header carries the token raw (AWS Cognito-publish shape),
|
|
@@ -6,7 +6,6 @@ import * as Graphql from "graphql";
|
|
|
6
6
|
import * as GraphqlYoga from "@reventlessdev/rescript-graphql-yoga/src/GraphqlYoga.res.mjs";
|
|
7
7
|
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
8
8
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
9
|
-
import * as Nodecrypto from "node:crypto";
|
|
10
9
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
11
10
|
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
12
11
|
import * as GraphqlYoga$1 from "graphql-yoga";
|
|
@@ -100,37 +99,12 @@ function handleLogout(_req, res) {
|
|
|
100
99
|
res.end(null);
|
|
101
100
|
}
|
|
102
101
|
|
|
103
|
-
let uploadPresignPath = "/__inmemory/upload";
|
|
104
|
-
|
|
105
102
|
let _corsWriteHeaders = {
|
|
106
103
|
"Access-Control-Allow-Origin": "*",
|
|
107
104
|
"Access-Control-Allow-Methods": "PUT,GET,OPTIONS",
|
|
108
105
|
"Access-Control-Allow-Headers": "*"
|
|
109
106
|
};
|
|
110
107
|
|
|
111
|
-
function handleUploadPresign(req, res) {
|
|
112
|
-
readBody(req, body => {
|
|
113
|
-
let parsed;
|
|
114
|
-
try {
|
|
115
|
-
parsed = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(JSON.parse(body)), {});
|
|
116
|
-
} catch (exn) {
|
|
117
|
-
parsed = {};
|
|
118
|
-
}
|
|
119
|
-
let fileName = Stdlib_Option.getOr(Stdlib_Option.flatMap(parsed["fileName"], Stdlib_JSON.Decode.string), "upload");
|
|
120
|
-
let ref = `/` + LocalObjectStore$ReventlessLocal.defaultUploadPrefix + `/` + Nodecrypto.randomUUID() + `/` + fileName;
|
|
121
|
-
_writeJson(res, 200, Object.fromEntries([
|
|
122
|
-
[
|
|
123
|
-
"uploadUrl",
|
|
124
|
-
ref
|
|
125
|
-
],
|
|
126
|
-
[
|
|
127
|
-
"storageRef",
|
|
128
|
-
ref
|
|
129
|
-
]
|
|
130
|
-
]));
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
108
|
function handleObjectPut(req, res, key) {
|
|
135
109
|
let contentType = Stdlib_Option.getOr(req.headers["content-type"], "application/octet-stream");
|
|
136
110
|
readBodyBuf(req, bytes => {
|
|
@@ -191,9 +165,6 @@ function _dispatch(req, res, yoga, getSdl) {
|
|
|
191
165
|
if (path === "/__inmemory/logout" && req.method === "POST") {
|
|
192
166
|
return handleLogout(req, res);
|
|
193
167
|
}
|
|
194
|
-
if (path === uploadPresignPath && req.method === "POST") {
|
|
195
|
-
return handleUploadPresign(req, res);
|
|
196
|
-
}
|
|
197
168
|
if (path !== "/events" || req.method !== "POST") {
|
|
198
169
|
if (path === "/events" && req.method === "OPTIONS") {
|
|
199
170
|
res.writeHead(204, {
|
|
@@ -776,9 +747,7 @@ export {
|
|
|
776
747
|
_loginRejected,
|
|
777
748
|
handleLogin,
|
|
778
749
|
handleLogout,
|
|
779
|
-
uploadPresignPath,
|
|
780
750
|
_corsWriteHeaders,
|
|
781
|
-
handleUploadPresign,
|
|
782
751
|
handleObjectPut,
|
|
783
752
|
handleObjectGet,
|
|
784
753
|
_isInvalidBearer,
|
|
@@ -18,12 +18,8 @@ let handleLogin = DomainGraphQL_Server$ReventlessLocal.handleLogin;
|
|
|
18
18
|
|
|
19
19
|
let handleLogout = DomainGraphQL_Server$ReventlessLocal.handleLogout;
|
|
20
20
|
|
|
21
|
-
let uploadPresignPath = DomainGraphQL_Server$ReventlessLocal.uploadPresignPath;
|
|
22
|
-
|
|
23
21
|
let _corsWriteHeaders = DomainGraphQL_Server$ReventlessLocal._corsWriteHeaders;
|
|
24
22
|
|
|
25
|
-
let handleUploadPresign = DomainGraphQL_Server$ReventlessLocal.handleUploadPresign;
|
|
26
|
-
|
|
27
23
|
let handleObjectPut = DomainGraphQL_Server$ReventlessLocal.handleObjectPut;
|
|
28
24
|
|
|
29
25
|
let handleObjectGet = DomainGraphQL_Server$ReventlessLocal.handleObjectGet;
|
|
@@ -137,9 +133,7 @@ export {
|
|
|
137
133
|
_loginRejected,
|
|
138
134
|
handleLogin,
|
|
139
135
|
handleLogout,
|
|
140
|
-
uploadPresignPath,
|
|
141
136
|
_corsWriteHeaders,
|
|
142
|
-
handleUploadPresign,
|
|
143
137
|
handleObjectPut,
|
|
144
138
|
handleObjectGet,
|
|
145
139
|
_isInvalidBearer,
|
|
@@ -56,6 +56,12 @@ let put = (~key: string, ~bytes: buffer, ~contentType: string): unit =>
|
|
|
56
56
|
|
|
57
57
|
let get = (~key: string): option<entry> => objects->Dict.get(key)
|
|
58
58
|
|
|
59
|
+
// Remove a stored object. Idempotent — deleting an absent key is a no-op, matching
|
|
60
|
+
// the release contract (see docs/plans/upload-release-path.md, Step 3). The dev
|
|
61
|
+
// store has no identities or clock, so the release resolver enforces only the
|
|
62
|
+
// *shape* of the rule (key under a served prefix), not the identity/age conditions.
|
|
63
|
+
let delete = (~key: string): unit => objects->Dict.delete(key)
|
|
64
|
+
|
|
59
65
|
// Clear stored objects and restore the default served prefix — used between
|
|
60
66
|
// isolated test suites (called from DomainGraphQL_Server.reset).
|
|
61
67
|
let reset = (): unit => {
|
|
@@ -37,6 +37,10 @@ function get(key) {
|
|
|
37
37
|
return objects[key];
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function $$delete(key) {
|
|
41
|
+
Stdlib_Dict.$$delete(objects, key);
|
|
42
|
+
}
|
|
43
|
+
|
|
40
44
|
function reset() {
|
|
41
45
|
Object.keys(objects).forEach(k => Stdlib_Dict.$$delete(objects, k));
|
|
42
46
|
servedPrefixes.contents = [defaultUploadPrefix];
|
|
@@ -50,6 +54,7 @@ export {
|
|
|
50
54
|
servedKey,
|
|
51
55
|
put,
|
|
52
56
|
get,
|
|
57
|
+
$$delete,
|
|
53
58
|
reset,
|
|
54
59
|
}
|
|
55
60
|
/* No side effect */
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Local upload resolver logic (route B), backed by `LocalObjectStore`. Shared by the
|
|
2
|
+
// dev platform (`Platform.res` registers these on the domain server) and
|
|
3
|
+
// `ServedBucketHttpTest`, so the mint/release behaviour has one definition.
|
|
4
|
+
//
|
|
5
|
+
// The dev store has no identities and no clock, so release enforces only the *shape*
|
|
6
|
+
// of the rule (key under a served prefix), not the identity/age conditions AWS applies
|
|
7
|
+
// — dev parity is about the client seeing the same contract, not reproducing AWS's
|
|
8
|
+
// guarantees. See [docs/plans/upload-release-path.md] § Step 3.
|
|
9
|
+
|
|
10
|
+
// Mint a same-origin `/{prefix}/{uuid}/{fileName}` ref: both the PUT target and the
|
|
11
|
+
// stored value, mirroring the AWS presign ticket's shape.
|
|
12
|
+
let mintRef = (~fileName: string): string =>
|
|
13
|
+
`/${LocalObjectStore.defaultUploadPrefix}/${NodeCrypto.randomUUID()}/${fileName}`
|
|
14
|
+
|
|
15
|
+
// Release a ref: delete iff it sits under a served prefix; idempotent (deleting an
|
|
16
|
+
// absent key still succeeds). Returns `(released, reason)`.
|
|
17
|
+
let release = (~storageRef: string): (bool, option<string>) =>
|
|
18
|
+
switch LocalObjectStore.servedKey(storageRef) {
|
|
19
|
+
| Some(key) =>
|
|
20
|
+
LocalObjectStore.delete(~key)
|
|
21
|
+
(true, None)
|
|
22
|
+
| None => (false, Some("not_in_store"))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Register `Upload_Presign` / `Upload_Release` (types + mutation fields + resolvers) on
|
|
26
|
+
// a GraphQL server instance — the local analogue of adding them to `domainBaseFragment`.
|
|
27
|
+
let register = (server: ReventlessGraphqlServer.GraphQL_ServerInstance.t): unit => {
|
|
28
|
+
let resolvers = Dict.make()
|
|
29
|
+
resolvers->Dict.set("Upload_Presign", async (_root, args, _ctx): JSON.t => {
|
|
30
|
+
let obj = args->JSON.Decode.object->Option.getOr(Dict.make())
|
|
31
|
+
let fileName =
|
|
32
|
+
obj->Dict.get("fileName")->Option.flatMap(JSON.Decode.string)->Option.getOr("upload")
|
|
33
|
+
let ref = mintRef(~fileName)
|
|
34
|
+
Dict.fromArray([
|
|
35
|
+
("uploadUrl", JSON.Encode.string(ref)),
|
|
36
|
+
("storageRef", JSON.Encode.string(ref)),
|
|
37
|
+
])->JSON.Encode.object
|
|
38
|
+
})
|
|
39
|
+
resolvers->Dict.set("Upload_Release", async (_root, args, _ctx): JSON.t => {
|
|
40
|
+
let obj = args->JSON.Decode.object->Option.getOr(Dict.make())
|
|
41
|
+
let storageRef =
|
|
42
|
+
obj->Dict.get("storageRef")->Option.flatMap(JSON.Decode.string)->Option.getOr("")
|
|
43
|
+
let (released, reason) = release(~storageRef)
|
|
44
|
+
Dict.fromArray([
|
|
45
|
+
("released", JSON.Encode.bool(released)),
|
|
46
|
+
("reason", reason->Option.mapOr(JSON.Null, JSON.Encode.string)),
|
|
47
|
+
])->JSON.Encode.object
|
|
48
|
+
})
|
|
49
|
+
server.registerTypes(~sdlTypes=ReventlessCore.Platform_AdminApi.uploadTypes)
|
|
50
|
+
server.registerMutations(
|
|
51
|
+
~sdlFields=ReventlessCore.Platform_AdminApi.uploadMutationFields,
|
|
52
|
+
~resolvers,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
|
+
import * as Nodecrypto from "node:crypto";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as LocalObjectStore$ReventlessLocal from "./LocalObjectStore.res.mjs";
|
|
7
|
+
import * as Platform_AdminApi$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_AdminApi.res.mjs";
|
|
8
|
+
|
|
9
|
+
function mintRef(fileName) {
|
|
10
|
+
return `/` + LocalObjectStore$ReventlessLocal.defaultUploadPrefix + `/` + Nodecrypto.randomUUID() + `/` + fileName;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function release(storageRef) {
|
|
14
|
+
let key = LocalObjectStore$ReventlessLocal.servedKey(storageRef);
|
|
15
|
+
if (key !== undefined) {
|
|
16
|
+
LocalObjectStore$ReventlessLocal.$$delete(key);
|
|
17
|
+
return [
|
|
18
|
+
true,
|
|
19
|
+
undefined
|
|
20
|
+
];
|
|
21
|
+
} else {
|
|
22
|
+
return [
|
|
23
|
+
false,
|
|
24
|
+
"not_in_store"
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function register(server) {
|
|
30
|
+
let resolvers = {};
|
|
31
|
+
resolvers["Upload_Presign"] = async (_root, args, _ctx) => {
|
|
32
|
+
let obj = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(args), {});
|
|
33
|
+
let fileName = Stdlib_Option.getOr(Stdlib_Option.flatMap(obj["fileName"], Stdlib_JSON.Decode.string), "upload");
|
|
34
|
+
let ref = mintRef(fileName);
|
|
35
|
+
return Object.fromEntries([
|
|
36
|
+
[
|
|
37
|
+
"uploadUrl",
|
|
38
|
+
ref
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
"storageRef",
|
|
42
|
+
ref
|
|
43
|
+
]
|
|
44
|
+
]);
|
|
45
|
+
};
|
|
46
|
+
resolvers["Upload_Release"] = async (_root, args, _ctx) => {
|
|
47
|
+
let obj = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(args), {});
|
|
48
|
+
let storageRef = Stdlib_Option.getOr(Stdlib_Option.flatMap(obj["storageRef"], Stdlib_JSON.Decode.string), "");
|
|
49
|
+
let match = release(storageRef);
|
|
50
|
+
return Object.fromEntries([
|
|
51
|
+
[
|
|
52
|
+
"released",
|
|
53
|
+
match[0]
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
"reason",
|
|
57
|
+
Stdlib_Option.mapOr(match[1], null, prim => prim)
|
|
58
|
+
]
|
|
59
|
+
]);
|
|
60
|
+
};
|
|
61
|
+
server.registerTypes(Platform_AdminApi$ReventlessCore.uploadTypes);
|
|
62
|
+
server.registerMutations(Platform_AdminApi$ReventlessCore.uploadMutationFields, resolvers);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export {
|
|
66
|
+
mintRef,
|
|
67
|
+
release,
|
|
68
|
+
register,
|
|
69
|
+
}
|
|
70
|
+
/* node:crypto Not a pure module */
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
// End-to-end test for the local
|
|
2
|
-
//
|
|
3
|
-
// private port
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
1
|
+
// End-to-end test for the local upload contract on DomainGraphQL_Server (route B, the
|
|
2
|
+
// dev analogue of the AWS presign + serve + release loop). Boots a real server on a
|
|
3
|
+
// private port with the Upload resolvers registered (as `makePlatform` does), then
|
|
4
|
+
// exercises the full loop:
|
|
5
|
+
// mutation Upload_Presign → {uploadUrl, storageRef}
|
|
6
|
+
// PUT /{prefix}/{key} → 200 (store)
|
|
7
|
+
// GET /{prefix}/{key} → the stored bytes + content-type
|
|
8
|
+
// mutation Upload_Release → {released: true}
|
|
9
|
+
// GET /{prefix}/{key} → 404 (gone)
|
|
10
|
+
// Talks to the server over node:http (Jest 27's VM strips global fetch), same as
|
|
11
|
+
// LocalAuthHttpTest. Mint and release are GraphQL mutations under route B; the byte
|
|
12
|
+
// PUT/GET stay HTTP data-plane routes.
|
|
9
13
|
|
|
10
14
|
@@warning("-44")
|
|
11
15
|
|
|
@@ -120,8 +124,37 @@ let getString = (j: JSON.t, k: string): string =>
|
|
|
120
124
|
|
|
121
125
|
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
|
122
126
|
|
|
127
|
+
// Extract `data.r` from a GraphQL response body.
|
|
128
|
+
let mutationResult = (body: JSON.t): JSON.t =>
|
|
129
|
+
body
|
|
130
|
+
->JSON.Decode.object
|
|
131
|
+
->Option.flatMap(d => d->Dict.get("data"))
|
|
132
|
+
->Option.flatMap(JSON.Decode.object)
|
|
133
|
+
->Option.flatMap(d => d->Dict.get("r"))
|
|
134
|
+
->Option.getOr(JSON.Encode.null)
|
|
135
|
+
|
|
136
|
+
let presign = async (~fileName: string): JSON.t => {
|
|
137
|
+
let q = `mutation { r: Upload_Presign(store: "x.y", fileName: "${fileName}", contentType: "image/svg+xml") { uploadUrl storageRef } }`
|
|
138
|
+
let (_, body) = await postJson("/graphql", Dict.fromArray([("query", JSON.Encode.string(q))]))
|
|
139
|
+
mutationResult(body)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let releaseRef = async (~storageRef: string): JSON.t => {
|
|
143
|
+
let q = `mutation { r: Upload_Release(store: "x.y", storageRef: "${storageRef}") { released reason } }`
|
|
144
|
+
let (_, body) = await postJson("/graphql", Dict.fromArray([("query", JSON.Encode.string(q))]))
|
|
145
|
+
mutationResult(body)
|
|
146
|
+
}
|
|
147
|
+
|
|
123
148
|
beforeAllAsync(async () => {
|
|
124
149
|
DomainGraphQL_Server.reset()
|
|
150
|
+
// Minimal valid schema: relay base (a Query type must exist) plus the Upload
|
|
151
|
+
// mutations, registered exactly as makePlatform does via LocalUploadResolvers.
|
|
152
|
+
DomainGraphQL_Server.registerTypes(~sdlTypes=ReventlessCore.GraphQL_Stitcher.relayBaseTypes)
|
|
153
|
+
DomainGraphQL_Server.registerQueries(
|
|
154
|
+
~sdlFields=ReventlessCore.GraphQL_Stitcher.relayBaseQueries,
|
|
155
|
+
~resolvers=Dict.make(),
|
|
156
|
+
)
|
|
157
|
+
LocalUploadResolvers.register(DomainGraphQL_Server.asInterface)
|
|
125
158
|
DomainGraphQL_Server.start(~port, ())
|
|
126
159
|
await Promise.make((resolve, _) => setTimeout(() => resolve(), 50)->ignore)
|
|
127
160
|
})
|
|
@@ -132,31 +165,19 @@ afterAll(() => {
|
|
|
132
165
|
|
|
133
166
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
134
167
|
|
|
135
|
-
testPromise("
|
|
136
|
-
let
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
Dict.fromArray([
|
|
140
|
-
("fileName", JSON.Encode.string("logo.svg")),
|
|
141
|
-
("contentType", JSON.Encode.string("image/svg+xml")),
|
|
142
|
-
]),
|
|
143
|
-
)
|
|
144
|
-
expect(status)->toEqual(200)
|
|
145
|
-
let uploadUrl = getString(body, "uploadUrl")
|
|
146
|
-
let storageRef = getString(body, "storageRef")
|
|
168
|
+
testPromise("Upload_Presign returns matching uploadUrl + storageRef under the prefix", async () => {
|
|
169
|
+
let r = await presign(~fileName="logo.svg")
|
|
170
|
+
let uploadUrl = getString(r, "uploadUrl")
|
|
171
|
+
let storageRef = getString(r, "storageRef")
|
|
147
172
|
// Same same-origin ref serves as both the PUT target and the stored value.
|
|
148
173
|
expect(uploadUrl)->toEqual(storageRef)
|
|
149
174
|
expect(storageRef->String.startsWith("/uploads/"))->toEqual(true)
|
|
150
175
|
expect(storageRef->String.endsWith("/logo.svg"))->toEqual(true)
|
|
151
176
|
})
|
|
152
177
|
|
|
153
|
-
testPromise("PUT
|
|
154
|
-
let
|
|
155
|
-
|
|
156
|
-
"/__inmemory/upload",
|
|
157
|
-
Dict.fromArray([("fileName", JSON.Encode.string("pixel.svg"))]),
|
|
158
|
-
)
|
|
159
|
-
let ref = getString(body, "storageRef")
|
|
178
|
+
testPromise("presign → PUT → GET → release → GET(404) is the full loop", async () => {
|
|
179
|
+
let r = await presign(~fileName="pixel.svg")
|
|
180
|
+
let ref = getString(r, "storageRef")
|
|
160
181
|
let svg = "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'></svg>"
|
|
161
182
|
|
|
162
183
|
let putStatus = await putRaw(ref, svg, ~contentType="image/svg+xml")
|
|
@@ -166,6 +187,23 @@ testPromise("PUT then GET round-trips the bytes and content-type", async () => {
|
|
|
166
187
|
expect(getStatus)->toEqual(200)
|
|
167
188
|
expect(gotBody)->toEqual(svg)
|
|
168
189
|
expect(gotContentType)->toEqual("image/svg+xml")
|
|
190
|
+
|
|
191
|
+
let released = await releaseRef(~storageRef=ref)
|
|
192
|
+
expect(released->JSON.Decode.object->Option.flatMap(d => d->Dict.get("released")))->toEqual(
|
|
193
|
+
Some(JSON.Encode.bool(true)),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
// After release the object is gone.
|
|
197
|
+
let (goneStatus, _, _) = await getRaw(ref)
|
|
198
|
+
expect(goneStatus)->toEqual(404)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
testPromise("Upload_Release of a ref outside a served prefix is refused with a reason", async () => {
|
|
202
|
+
let r = await releaseRef(~storageRef="/not-a-store/x/y.svg")
|
|
203
|
+
expect(r->JSON.Decode.object->Option.flatMap(d => d->Dict.get("released")))->toEqual(
|
|
204
|
+
Some(JSON.Encode.bool(false)),
|
|
205
|
+
)
|
|
206
|
+
expect(getString(r, "reason"))->toEqual("not_in_store")
|
|
169
207
|
})
|
|
170
208
|
|
|
171
209
|
testPromise("GET a missing served object returns 404", async () => {
|
|
@@ -4,7 +4,9 @@ import * as Nodehttp from "node:http";
|
|
|
4
4
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
6
|
import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
|
|
7
|
+
import * as GraphQL_Stitcher$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_Stitcher.res.mjs";
|
|
7
8
|
import * as DomainGraphQL_Server$ReventlessLocal from "../../src/adapter/DomainGraphQL_Server.res.mjs";
|
|
9
|
+
import * as LocalUploadResolvers$ReventlessLocal from "../../src/adapter/LocalUploadResolvers.res.mjs";
|
|
8
10
|
|
|
9
11
|
TestRunner$ReventlessLocal.setup();
|
|
10
12
|
|
|
@@ -115,8 +117,33 @@ function getString(j, k) {
|
|
|
115
117
|
return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(j), d => d[k]), Stdlib_JSON.Decode.string), "");
|
|
116
118
|
}
|
|
117
119
|
|
|
120
|
+
function mutationResult(body) {
|
|
121
|
+
return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(body), d => d["data"]), Stdlib_JSON.Decode.object), d => d["r"]), null);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function presign(fileName) {
|
|
125
|
+
let q = `mutation { r: Upload_Presign(store: "x.y", fileName: "` + fileName + `", contentType: "image/svg+xml") { uploadUrl storageRef } }`;
|
|
126
|
+
let match = await postJson("/graphql", Object.fromEntries([[
|
|
127
|
+
"query",
|
|
128
|
+
q
|
|
129
|
+
]]));
|
|
130
|
+
return mutationResult(match[1]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function releaseRef(storageRef) {
|
|
134
|
+
let q = `mutation { r: Upload_Release(store: "x.y", storageRef: "` + storageRef + `") { released reason } }`;
|
|
135
|
+
let match = await postJson("/graphql", Object.fromEntries([[
|
|
136
|
+
"query",
|
|
137
|
+
q
|
|
138
|
+
]]));
|
|
139
|
+
return mutationResult(match[1]);
|
|
140
|
+
}
|
|
141
|
+
|
|
118
142
|
globalThis.beforeAll(async () => {
|
|
119
143
|
DomainGraphQL_Server$ReventlessLocal.reset();
|
|
144
|
+
DomainGraphQL_Server$ReventlessLocal.registerTypes(GraphQL_Stitcher$ReventlessCore.relayBaseTypes);
|
|
145
|
+
DomainGraphQL_Server$ReventlessLocal.registerQueries(GraphQL_Stitcher$ReventlessCore.relayBaseQueries, {});
|
|
146
|
+
LocalUploadResolvers$ReventlessLocal.register(DomainGraphQL_Server$ReventlessLocal.asInterface);
|
|
120
147
|
DomainGraphQL_Server$ReventlessLocal.start(49322, undefined, undefined);
|
|
121
148
|
return await new Promise((resolve, param) => {
|
|
122
149
|
setTimeout(() => resolve(), 50);
|
|
@@ -125,39 +152,35 @@ globalThis.beforeAll(async () => {
|
|
|
125
152
|
|
|
126
153
|
globalThis.afterAll(() => DomainGraphQL_Server$ReventlessLocal.stop());
|
|
127
154
|
|
|
128
|
-
globalThis.test("
|
|
129
|
-
let
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
"logo.svg"
|
|
133
|
-
],
|
|
134
|
-
[
|
|
135
|
-
"contentType",
|
|
136
|
-
"image/svg+xml"
|
|
137
|
-
]
|
|
138
|
-
]));
|
|
139
|
-
let body = match[1];
|
|
140
|
-
globalThis.expect(match[0]).toEqual(200);
|
|
141
|
-
let uploadUrl = getString(body, "uploadUrl");
|
|
142
|
-
let storageRef = getString(body, "storageRef");
|
|
155
|
+
globalThis.test("Upload_Presign returns matching uploadUrl + storageRef under the prefix", async () => {
|
|
156
|
+
let r = await presign("logo.svg");
|
|
157
|
+
let uploadUrl = getString(r, "uploadUrl");
|
|
158
|
+
let storageRef = getString(r, "storageRef");
|
|
143
159
|
globalThis.expect(uploadUrl).toEqual(storageRef);
|
|
144
160
|
globalThis.expect(storageRef.startsWith("/uploads/")).toEqual(true);
|
|
145
161
|
globalThis.expect(storageRef.endsWith("/logo.svg")).toEqual(true);
|
|
146
162
|
});
|
|
147
163
|
|
|
148
|
-
globalThis.test("PUT
|
|
149
|
-
let
|
|
150
|
-
|
|
151
|
-
"pixel.svg"
|
|
152
|
-
]]));
|
|
153
|
-
let ref = getString(match[1], "storageRef");
|
|
164
|
+
globalThis.test("presign → PUT → GET → release → GET(404) is the full loop", async () => {
|
|
165
|
+
let r = await presign("pixel.svg");
|
|
166
|
+
let ref = getString(r, "storageRef");
|
|
154
167
|
let svg = "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'></svg>";
|
|
155
168
|
let putStatus = await putRaw(ref, svg, "image/svg+xml");
|
|
156
169
|
globalThis.expect(putStatus).toEqual(200);
|
|
170
|
+
let match = await getRaw(ref);
|
|
171
|
+
globalThis.expect(match[0]).toEqual(200);
|
|
172
|
+
globalThis.expect(match[1]).toEqual(svg);
|
|
173
|
+
globalThis.expect(match[2]).toEqual("image/svg+xml");
|
|
174
|
+
let released = await releaseRef(ref);
|
|
175
|
+
globalThis.expect(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(released), d => d["released"])).toEqual(true);
|
|
157
176
|
let match$1 = await getRaw(ref);
|
|
158
|
-
globalThis.expect(match$1[0]).toEqual(
|
|
159
|
-
|
|
160
|
-
|
|
177
|
+
globalThis.expect(match$1[0]).toEqual(404);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
globalThis.test("Upload_Release of a ref outside a served prefix is refused with a reason", async () => {
|
|
181
|
+
let r = await releaseRef("/not-a-store/x/y.svg");
|
|
182
|
+
globalThis.expect(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(r), d => d["released"])).toEqual(false);
|
|
183
|
+
globalThis.expect(getString(r, "reason")).toEqual("not_in_store");
|
|
161
184
|
});
|
|
162
185
|
|
|
163
186
|
globalThis.test("GET a missing served object returns 404", async () => {
|
|
@@ -173,5 +196,8 @@ export {
|
|
|
173
196
|
putRaw,
|
|
174
197
|
getRaw,
|
|
175
198
|
getString,
|
|
199
|
+
mutationResult,
|
|
200
|
+
presign,
|
|
201
|
+
releaseRef,
|
|
176
202
|
}
|
|
177
203
|
/* Not a pure module */
|