@reventlessdev/reventless-local 3.0.0-alpha.186 → 3.0.0-alpha.188

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 CHANGED
@@ -3,6 +3,33 @@
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.188 (2026-08-01)
7
+
8
+ * feat(aws,core,spec)!: qualify store prefixes by plugin and refuse name collisions ([da39405](https://github.com/ReventlessDev/reventless-core/commit/da394059d9f8f981bf7adc79e2c1ce2b429e0267))
9
+ * feat(core,aws)!: name task buckets for their plugin and task ([05203cb](https://github.com/ReventlessDev/reventless-core/commit/05203cb3b0b019401a6f32cf78109f397b6799d5))
10
+
11
+ ### BREAKING CHANGES
12
+
13
+ * objects minted under the old bare prefix are orphaned and their
14
+ refs unresolvable. The migration is `seed:reset` for the owning plugin, then
15
+ re-seed. Legacy-prefix grandfathering was considered and deliberately dropped —
16
+ it would have added a permanent prefix SET across the deploy argument, both store
17
+ configs, the presign IAM fan-out, the release scope check and the stack output,
18
+ to spare a disposable stack one wipe.
19
+ * a bucket name change is a replace. TaskBucket_S3 now sets
20
+ forceDestroy on disposable stacks (Util_StoreLayout.protectionFor), so pr-*
21
+ stacks recreate cleanly; protected stacks must have the task bucket drained by
22
+ hand before the first deploy after this, or the replace fails BucketNotEmpty.
23
+
24
+
25
+
26
+ # 3.0.0-alpha.187 (2026-08-01)
27
+
28
+ ### Features
29
+
30
+ * **upload:** add the release half of the upload contract via the domain API ([448f887](https://github.com/ReventlessDev/reventless-core/commit/448f88714a5875be420f794a08d482fcf4ba8404))
31
+
32
+
6
33
  # 3.0.0-alpha.186 (2026-08-01)
7
34
 
8
35
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.186",
3
+ "version": "3.0.0-alpha.188",
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": {
@@ -34,18 +34,18 @@
34
34
  "graphql-yoga": "^5.21.0",
35
35
  "sury": "11.0.0-alpha.4",
36
36
  "ws": "^8.18.0",
37
- "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
38
37
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
39
38
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.26",
40
- "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
39
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
41
40
  "@reventlessdev/rescript-node": "2.0.0-alpha.0",
41
+ "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
42
42
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
43
- "@reventlessdev/reventless-core": "3.0.0-alpha.198",
44
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.46",
45
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.145",
46
- "@reventlessdev/reventless-infra": "3.0.0-alpha.115",
47
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.62",
48
- "@reventlessdev/reventless-spec": "3.0.0-alpha.90"
43
+ "@reventlessdev/reventless-core": "3.0.0-alpha.200",
44
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.48",
45
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.147",
46
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.64",
47
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.92",
48
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.117"
49
49
  },
50
50
  "devDependencies": {
51
51
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -865,6 +865,40 @@ module MakeWithConfig = (
865
865
  }
866
866
  })
867
867
  })
868
+
869
+ // Two plugins declaring one store name is refused by the deployed platform
870
+ // before it provisions anything. Local provisions no stores, so nothing here
871
+ // breaks — but this is the only place a developer meets the rule while the
872
+ // fix is still free: once a store has deployed, its minted refs are in an
873
+ // append-only event log and renaming it strands them. Hence a warning at
874
+ // composition, and the authoritative refusal at deploy.
875
+ let declared =
876
+ pluginStructuresStore.contents
877
+ ->Dict.valuesToArray
878
+ ->Array.flatMap(structure =>
879
+ structure.requiredStores
880
+ ->Option.getOr([])
881
+ ->Array.map(qualified => {
882
+ ReventlessCore.StorePrefixCollision.qualified,
883
+ // Local provisions no stores, so it has no prefix of its own — it
884
+ // restates the deployed rule (`{plugin}/{store}`) so the warning it
885
+ // gives matches the refusal a deploy would give. Reporting a collision
886
+ // the deployed platform does not have would be worse than silence.
887
+ prefix: switch qualified->String.indexOfOpt(".") {
888
+ | Some(i) =>
889
+ qualified->String.slice(~start=0, ~end=i) ++
890
+ "/" ++
891
+ qualified->String.slice(~start=i + 1, ~end=qualified->String.length)
892
+ | None => qualified
893
+ },
894
+ })
895
+ )
896
+ ReventlessCore.StorePrefixCollision.collisionsFor(~stores=declared)->Array.forEach(c =>
897
+ log.warn(
898
+ ~comp="Platform:plugins",
899
+ ReventlessCore.StorePrefixCollision.collisionMessage(c),
900
+ )
901
+ )
868
902
  }
869
903
 
870
904
  // Bus keys for the admin Plugin aggregate (name-keyed). ComponentType.toName maps
@@ -1901,6 +1935,9 @@ module MakeWithConfig = (
1901
1935
  ~sdlFields=ReventlessCore.GraphQL_Stitcher.relayBaseQueries,
1902
1936
  ~resolvers=Dict.make(),
1903
1937
  )
1938
+ // Upload service (route B) on the domain server — mirrors adding it to
1939
+ // `domainBaseFragment` on AWS.
1940
+ LocalUploadResolvers.register(DomainGraphQL_Server.asInterface)
1904
1941
  // In split mode, inject Relay base types (Node interface, PageInfo) into the platform
1905
1942
  // server so SDL fragments compile. The node(id) query is Domain-only — the Platform
1906
1943
  // API is consumed by admin tools and agents, not Relay clients.
@@ -55,8 +55,10 @@ import * as PlatformMCP_Server$ReventlessLocal from "./adapter/PlatformMCP_Serve
55
55
  import * as Auth_GraphqlContext$ReventlessLocal from "./adapter/Auth/Auth_GraphqlContext.res.mjs";
56
56
  import * as PgProjectionCatchup$ReventlessLocal from "./adapter/PgProjectionCatchup.res.mjs";
57
57
  import * as PluginsReadModelSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs";
58
+ import * as StorePrefixCollision$ReventlessCore from "@reventlessdev/reventless-core/src/util/StorePrefixCollision.res.mjs";
58
59
  import * as DomainGraphQL_Server$ReventlessLocal from "./adapter/DomainGraphQL_Server.res.mjs";
59
60
  import * as EventPublish_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/EventLog/EventPublish_Callback.res.mjs";
61
+ import * as LocalUploadResolvers$ReventlessLocal from "./adapter/LocalUploadResolvers.res.mjs";
60
62
  import * as ProjectionCheckpoint$ReventlessLocal from "./adapter/ProjectionCheckpoint.res.mjs";
61
63
  import * as ExtensionPointMapping$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/ExtensionPointMapping.res.mjs";
62
64
  import * as UiFragments_Projection$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateViewSlice/UiFragments_Projection.res.mjs";
@@ -823,6 +825,14 @@ function MakeWithConfig(Config) {
823
825
  }
824
826
  });
825
827
  });
828
+ let declared = Object.values(pluginStructuresStore.contents).flatMap(structure => Stdlib_Option.getOr(structure.requiredStores, []).map(qualified => {
829
+ let i = Stdlib_String.indexOfOpt(qualified, ".");
830
+ return {
831
+ qualified: qualified,
832
+ prefix: i !== undefined ? qualified.slice(0, i) + "/" + qualified.slice(i + 1 | 0, qualified.length) : qualified
833
+ };
834
+ }));
835
+ StorePrefixCollision$ReventlessCore.collisionsFor(declared).forEach(c => log.warn("Platform:plugins", undefined, StorePrefixCollision$ReventlessCore.collisionMessage(c)));
826
836
  };
827
837
  let pluginCmdTopicKey = PluginSpec$ReventlessCore.name + "AggrCmdTopic";
828
838
  let pluginEventTopicKey = PluginSpec$ReventlessCore.name + "AggrEventTopic";
@@ -1542,6 +1552,7 @@ function MakeWithConfig(Config) {
1542
1552
  });
1543
1553
  DomainGraphQL_Server$ReventlessLocal.registerTypes(GraphQL_Stitcher$ReventlessCore.relayBaseTypes);
1544
1554
  DomainGraphQL_Server$ReventlessLocal.registerQueries(GraphQL_Stitcher$ReventlessCore.relayBaseQueries, {});
1555
+ LocalUploadResolvers$ReventlessLocal.register(DomainGraphQL_Server$ReventlessLocal.asInterface);
1545
1556
  if (Config.splitApi) {
1546
1557
  PlatformGraphQL_Server$ReventlessLocal.registerTypes(GraphQL_Stitcher$ReventlessCore.relayBaseTypes);
1547
1558
  }
@@ -2498,6 +2509,14 @@ function Make($star) {
2498
2509
  }
2499
2510
  });
2500
2511
  });
2512
+ let declared = Object.values(pluginStructuresStore.contents).flatMap(structure => Stdlib_Option.getOr(structure.requiredStores, []).map(qualified => {
2513
+ let i = Stdlib_String.indexOfOpt(qualified, ".");
2514
+ return {
2515
+ qualified: qualified,
2516
+ prefix: i !== undefined ? qualified.slice(0, i) + "/" + qualified.slice(i + 1 | 0, qualified.length) : qualified
2517
+ };
2518
+ }));
2519
+ StorePrefixCollision$ReventlessCore.collisionsFor(declared).forEach(c => log.warn("Platform:plugins", undefined, StorePrefixCollision$ReventlessCore.collisionMessage(c)));
2501
2520
  };
2502
2521
  let pluginCmdTopicKey = PluginSpec$ReventlessCore.name + "AggrCmdTopic";
2503
2522
  let pluginEventTopicKey = PluginSpec$ReventlessCore.name + "AggrEventTopic";
@@ -3212,6 +3231,7 @@ function Make($star) {
3212
3231
  });
3213
3232
  DomainGraphQL_Server$ReventlessLocal.registerTypes(GraphQL_Stitcher$ReventlessCore.relayBaseTypes);
3214
3233
  DomainGraphQL_Server$ReventlessLocal.registerQueries(GraphQL_Stitcher$ReventlessCore.relayBaseQueries, {});
3234
+ LocalUploadResolvers$ReventlessLocal.register(DomainGraphQL_Server$ReventlessLocal.asInterface);
3215
3235
  PlatformGraphQL_Server$ReventlessLocal.registerTypes(GraphQL_Stitcher$ReventlessCore.relayBaseTypes);
3216
3236
  currentDeployTarget.contents = "Domain";
3217
3237
  };
@@ -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 three routes back the same
130
- // UI contract locally against `LocalObjectStore`:
131
- // POST /__inmemory/upload {fileName,contentType} → {uploadUrl, storageRef}
132
- // PUT /{prefix}/{key} raw bytes200 (store)
133
- // GET /{prefix}/{key} → the stored bytes
134
- // `uploadUrl == storageRef == /{prefix}/{key}`, so the existing FileDropzone S3
135
- // adapter (POST-presign PUT-bytes) works unchanged — no presigning needed.
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 served-bucket HTTP routes on DomainGraphQL_Server
2
- // (the dev analogue of the AWS CloudFront read path). Boots a real server on a
3
- // private port, then exercises the full upload store serve loop:
4
- // POST /__inmemory/upload {uploadUrl, storageRef}
5
- // PUT /{prefix}/{key} 200 (store)
6
- // GET /{prefix}/{key} the stored bytes + content-type
7
- // Talks to the server over node:http (Jest 27's VM strips global fetch), same
8
- // as LocalAuthHttpTest.
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("POST /__inmemory/upload returns matching uploadUrl + storageRef under the prefix", async () => {
136
- let (status, body) =
137
- await postJson(
138
- "/__inmemory/upload",
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 then GET round-trips the bytes and content-type", async () => {
154
- let (_, body) =
155
- await postJson(
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("POST /__inmemory/upload returns matching uploadUrl + storageRef under the prefix", async () => {
129
- let match = await postJson("/__inmemory/upload", Object.fromEntries([
130
- [
131
- "fileName",
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 then GET round-trips the bytes and content-type", async () => {
149
- let match = await postJson("/__inmemory/upload", Object.fromEntries([[
150
- "fileName",
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(200);
159
- globalThis.expect(match$1[1]).toEqual(svg);
160
- globalThis.expect(match$1[2]).toEqual("image/svg+xml");
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 */
@@ -90,9 +90,12 @@ describe("Task_Builder.Make:", () => {
90
90
  let bucketNames = outputs.bucketNames->Option.getUnsafe
91
91
  let idOutput = bucketNames->Dict.get("Reports")->Option.getUnsafe
92
92
  let id = await idOutput->TestRunner.resolve
93
- // In-memory bucket id = resource name: taskName ++ pascalCase(bucketName) ++ "Bucket"
94
- // = "OneBucketTask" ++ "Reports" ++ "Bucket". The lookup key stays "Reports".
95
- expect(id)->toBe("OneBucketTaskReportsBucket")
93
+ // In-memory bucket id = the bucket's resource name, which is kebab-cased
94
+ // and plugin-qualified because S3 lowercases a bucket name and a PascalCase
95
+ // one collapses into a run-on. No plugin segment here: the task is built
96
+ // outside any plugin, so there is no ambient plugin to qualify with.
97
+ // The runtime lookup key is a separate string and stays "Reports".
98
+ expect(id)->toBe("one-bucket-task-reports")
96
99
  })
97
100
  })
98
101
  })
@@ -38,7 +38,7 @@ globalThis.describe("Task_Builder.Make:", () => {
38
38
  let bucketNames = outputs.bucketNames;
39
39
  let idOutput = bucketNames["Reports"];
40
40
  let id = await TestRunner$ReventlessLocal.resolve(idOutput);
41
- globalThis.expect(id).toBe("OneBucketTaskReportsBucket");
41
+ globalThis.expect(id).toBe("one-bucket-task-reports");
42
42
  });
43
43
  });
44
44
  });