@reventlessdev/reventless-local 3.0.0-alpha.199 → 3.0.0-alpha.201

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 (40) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +10 -9
  3. package/rescript.json +2 -1
  4. package/src/Platform.res +137 -86
  5. package/src/Platform.res.mjs +57 -44
  6. package/src/adapter/BackendState.res +13 -0
  7. package/src/adapter/BackendState.res.mjs +17 -1
  8. package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res.mjs +1 -1
  9. package/src/adapter/DomainGraphQL_Server.res +5 -5
  10. package/src/adapter/DomainGraphQL_Server.res.mjs +1 -1
  11. package/src/adapter/EventLog/LocalEventLogStorage.res.mjs +1 -1
  12. package/src/adapter/LocalBus.res +6 -46
  13. package/src/adapter/LocalBus.res.mjs +0 -29
  14. package/src/adapter/LocalStateChangeDescriptor.res +93 -0
  15. package/src/adapter/LocalStateChangeDescriptor.res.mjs +61 -0
  16. package/src/adapter/LocalUploadResolvers.res +16 -3
  17. package/src/adapter/LocalUploadResolvers.res.mjs +6 -4
  18. package/src/adapter/ObjectStore/LocalObjectStore.res +157 -0
  19. package/src/adapter/ObjectStore/LocalObjectStore.res.mjs +129 -0
  20. package/src/adapter/ObjectStore/ObjectStoreStorage_FileSystem.res +215 -0
  21. package/src/adapter/ObjectStore/ObjectStoreStorage_FileSystem.res.mjs +286 -0
  22. package/src/adapter/ObjectStore/ObjectStoreStorage_InMemory.res +33 -0
  23. package/src/adapter/ObjectStore/ObjectStoreStorage_InMemory.res.mjs +47 -0
  24. package/src/adapter/QueryDb/LocalQueryDbStorage.res.mjs +1 -1
  25. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +4 -2
  26. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +3 -3
  27. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +4 -2
  28. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +3 -3
  29. package/src/reset/LocalSeedReset.res +552 -0
  30. package/src/reset/LocalSeedReset.res.mjs +533 -0
  31. package/tests/adapter/BackendParityTest.res +51 -0
  32. package/tests/adapter/BackendParityTest.res.mjs +44 -0
  33. package/tests/adapter/GraphQL_SubscriptionResolversTest.res +6 -4
  34. package/tests/adapter/GraphQL_SubscriptionResolversTest.res.mjs +4 -3
  35. package/tests/adapter/ObjectStorePersistenceTest.res +243 -0
  36. package/tests/adapter/ObjectStorePersistenceTest.res.mjs +159 -0
  37. package/tests/reset/LocalSeedResetTest.res +252 -0
  38. package/tests/reset/LocalSeedResetTest.res.mjs +280 -0
  39. package/src/adapter/LocalObjectStore.res +0 -70
  40. package/src/adapter/LocalObjectStore.res.mjs +0 -60
@@ -27,7 +27,7 @@ type nodeResponse
27
27
  @send external end_: (nodeResponse, string) => unit = "end"
28
28
  @send external endEmpty: (nodeResponse, @as(json`null`) _) => unit = "end"
29
29
  // Binary response body — the served-object GET streams stored bytes verbatim.
30
- @send external endBuf: (nodeResponse, LocalObjectStore.buffer) => unit = "end"
30
+ @send external endBuf: (nodeResponse, NodeBuffer.t) => unit = "end"
31
31
 
32
32
  // Streaming body collector — request data fires once per chunk, end once at EOF.
33
33
  @send external _onData: (nodeRequest, @as("data") _, string => unit) => unit = "on"
@@ -35,7 +35,7 @@ type nodeResponse
35
35
  @send external _setEncoding: (nodeRequest, string) => unit = "setEncoding"
36
36
  // Binary chunk stream — used by the served-object PUT so raw bytes aren't
37
37
  // mangled by utf8 decoding (setEncoding is deliberately NOT called).
38
- @send external _onDataBuf: (nodeRequest, @as("data") _, LocalObjectStore.buffer => unit) => unit = "on"
38
+ @send external _onDataBuf: (nodeRequest, @as("data") _, NodeBuffer.t => unit) => unit = "on"
39
39
 
40
40
 
41
41
  let readBody = (req: nodeRequest, onBody: string => unit): unit => {
@@ -47,10 +47,10 @@ let readBody = (req: nodeRequest, onBody: string => unit): unit => {
47
47
 
48
48
  // Binary body collector — accumulates raw Buffer chunks and concatenates once
49
49
  // at EOF. Kept separate from `readBody` (utf8) so file uploads stay byte-exact.
50
- let readBodyBuf = (req: nodeRequest, onBody: LocalObjectStore.buffer => unit): unit => {
51
- let chunks: array<LocalObjectStore.buffer> = []
50
+ let readBodyBuf = (req: nodeRequest, onBody: NodeBuffer.t => unit): unit => {
51
+ let chunks: array<NodeBuffer.t> = []
52
52
  req->_onDataBuf(chunk => chunks->Array.push(chunk))
53
- req->_onEnd(() => onBody(LocalObjectStore.concatBuffers(chunks)))
53
+ req->_onEnd(() => onBody(NodeBuffer.concat(chunks)))
54
54
  }
55
55
 
56
56
  type requestHandler = (nodeRequest, nodeResponse) => unit
@@ -17,7 +17,7 @@ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_excep
17
17
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
18
18
  import * as LocalAuth$ReventlessLocal from "./Auth/LocalAuth.res.mjs";
19
19
  import * as GraphQL_Stitcher$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_Stitcher.res.mjs";
20
- import * as LocalObjectStore$ReventlessLocal from "./LocalObjectStore.res.mjs";
20
+ import * as LocalObjectStore$ReventlessLocal from "./ObjectStore/LocalObjectStore.res.mjs";
21
21
  import * as LocalEvents_Server$ReventlessLocal from "./Api/LocalEvents_Server.res.mjs";
22
22
  import * as Auth_GraphqlContext$ReventlessLocal from "./Auth/Auth_GraphqlContext.res.mjs";
23
23
 
@@ -32,4 +32,4 @@ function Make(Bus) {
32
32
  export {
33
33
  Make,
34
34
  }
35
- /* EventLogStorage_Sqlite-ReventlessLocal Not a pure module */
35
+ /* BackendState-ReventlessLocal Not a pure module */
@@ -47,48 +47,9 @@ type queuedEvent = {
47
47
  done_: Effect.t<unit, unit, unit>,
48
48
  }
49
49
 
50
- // ── State-change descriptor helper ───────────────────────────────────────────
51
- // Mirrors the JSON shape produced by the AWS StateTopic Lambda (Phase 2):
52
- // `{ changeKind, id, sortKeyValue? }`. Storage adapters call this and pass
53
- // the result to `publishStateChange(~descriptor)` so dev and prod subscribers
54
- // see the same payload.
55
-
56
- let pickSortKeyValue = (state: JSON.t): option<string> =>
57
- switch state->JSON.Decode.object {
58
- | Some(obj) =>
59
- switch obj->Dict.get("updatedAt") {
60
- | Some(JSON.String(v)) => Some(v)
61
- | _ =>
62
- switch obj->Dict.get("createdAt") {
63
- | Some(JSON.String(v)) => Some(v)
64
- | _ => None
65
- }
66
- }
67
- | None => None
68
- }
69
-
70
- /** Build a state-change descriptor.
71
- - `changeKind`: one of "Added" | "Updated" | "Removed". save() emits "Added"
72
- when no visible row held the key and "Updated" otherwise; delete() emits
73
- "Removed".
74
- - `id`: entity key. Single-key projections: the partition-key value.
75
- Composite projections: `partition ++ "-" ++ subKey`.
76
- - `state`: present for save() (for `sortKeyValue` extraction);
77
- `None` for delete() — descriptor will omit `sortKeyValue`. */
78
- let makeStateChangeDescriptor = (
79
- ~changeKind: string,
80
- ~id: string,
81
- ~state: option<JSON.t>,
82
- ): JSON.t => {
83
- let descriptor = Dict.make()
84
- descriptor->Dict.set("changeKind", JSON.Encode.string(changeKind))
85
- descriptor->Dict.set("id", JSON.Encode.string(id))
86
- switch state->Option.flatMap(pickSortKeyValue) {
87
- | Some(v) => descriptor->Dict.set("sortKeyValue", JSON.Encode.string(v))
88
- | None => ()
89
- }
90
- descriptor->JSON.Encode.object
91
- }
50
+ // The state-change descriptor itself lives in `LocalStateChangeDescriptor` —
51
+ // it is one of three implementations of a shared wire format and is covered by
52
+ // its own parity test, so it stays out of the bus.
92
53
 
93
54
  // Opt-in NDJSON domain-event tap for the VS Code local platform runner (features
94
55
  // plan Phase 9). Enabled when REVENTLESS_EVENT_TAP is set; off by default so normal
@@ -205,16 +166,15 @@ module type T = {
205
166
  //
206
167
  // Source B (state changes): QueryDbStorage_InMemory calls publishStateChange after
207
168
  // every save/delete so subscription listeners receive a change descriptor matching
208
- // the AWS StateTopic Lambda output (Phase 2): {changeKind, id, sortKeyValue?}.
169
+ // the AWS StateTopic Lambda output: {changeKind, id, sortKeyValue?, seq, state?}.
209
170
  // ~name is the QueryDb/ReadModel Spec.name; ~descriptor is built via
210
- // `makeStateChangeDescriptor` below.
171
+ // `LocalStateChangeDescriptor.make`.
211
172
  //
212
173
  // `changeKind` matches AWS, which reads it off the DynamoDB stream eventName:
213
174
  // save() checks whether a visible row already held the key and emits "Added"
214
175
  // or "Updated" accordingly, `delete()` emits "Removed". Without the "Added"
215
176
  // arm a list view drops every row it doesn't already hold, so seeding into an
216
- // empty read model looked like live updates were broken. `position` is
217
- // omitted (Phase 3 deferred).
177
+ // empty read model looked like live updates were broken.
218
178
  let publishStateChange: (~name: string, ~descriptor: JSON.t) => unit
219
179
  let subscribeToStateChanges: (string, JSON.t => unit) => unit
220
180
  // All-changes variant: receives every publishStateChange with its read-model
@@ -2,7 +2,6 @@
2
2
 
3
3
  import * as Effect from "@reventlessdev/rescript-effect/src/Effect.res.mjs";
4
4
  import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
5
- import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
6
5
  import * as Queue from "effect/Queue";
7
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
7
  import * as Effect$1 from "effect/Effect";
@@ -12,32 +11,6 @@ import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
12
11
  import * as Deferred from "effect/Deferred";
13
12
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
14
13
 
15
- function pickSortKeyValue(state) {
16
- let obj = Stdlib_JSON.Decode.object(state);
17
- if (obj === undefined) {
18
- return;
19
- }
20
- let match = obj["updatedAt"];
21
- if (typeof match === "string") {
22
- return match;
23
- }
24
- let match$1 = obj["createdAt"];
25
- if (typeof match$1 === "string") {
26
- return match$1;
27
- }
28
- }
29
-
30
- function makeStateChangeDescriptor(changeKind, id, state) {
31
- let descriptor = {};
32
- descriptor["changeKind"] = changeKind;
33
- descriptor["id"] = id;
34
- let v = Stdlib_Option.flatMap(state, pickSortKeyValue);
35
- if (v !== undefined) {
36
- descriptor["sortKeyValue"] = v;
37
- }
38
- return descriptor;
39
- }
40
-
41
14
  function eventTapEnabled() {
42
15
  return Stdlib_Option.isSome(process.env["REVENTLESS_EVENT_TAP"]);
43
16
  }
@@ -1187,8 +1160,6 @@ function MakeBounded(C) {
1187
1160
  }
1188
1161
 
1189
1162
  export {
1190
- pickSortKeyValue,
1191
- makeStateChangeDescriptor,
1192
1163
  eventTapEnabled,
1193
1164
  eventTapSeq,
1194
1165
  seedEventTapSeq,
@@ -0,0 +1,93 @@
1
+ // Local implementation of the live-update change descriptor — the JSON a
2
+ // subscriber receives on `/default/{listFieldName}/{entityKey}` after a read
3
+ // model row changes.
4
+ //
5
+ // { changeKind, id, sortKeyValue?, seq, state? }
6
+ //
7
+ // One wire format, three independent implementations: this one (both local
8
+ // backends), the DynamoDB stream relay in `StateTopic_AppSync_Ops`, and the
9
+ // Postgres projection-side publisher in `StateTopicPublish.mjs`. They share no
10
+ // code — the relay's module is deliberately Pulumi-free so a core import can't
11
+ // drag deploy-time code into its Lambda graph. `StateChangeDescriptorParityTest`
12
+ // (reventless-aws) drives all three and asserts they agree.
13
+ //
14
+ // `state` is ADVISORY. A subscriber may always ignore it and refetch; that is
15
+ // what keeps the channel best-effort. A protocol that required the payload to be
16
+ // applied would have to be lossless, and this one is not — see
17
+ // `docs/analysis/live-update-descriptor-sequencing.md`.
18
+
19
+ /** Cap on the serialised state payload, in characters.
20
+ AppSync Events caps a publish at ~240 KB, and the descriptor travels as a
21
+ JSON string nested inside the publish body, so escaping inflates it. Counting
22
+ characters rather than bytes keeps the rule identical across the three
23
+ implementations without a UTF-8 length binding; the limit is set low enough
24
+ that worst-case UTF-8 (3 bytes per character) plus escaping still fits.
25
+ Over the cap the state is dropped and the downgrade logged — a metadata-only
26
+ descriptor still tells the client to refetch, where a failed publish would
27
+ tell it nothing. */
28
+ let maxStateChars = 60 * 1024
29
+
30
+ let pickSortKeyValue = (state: JSON.t): option<string> =>
31
+ switch state->JSON.Decode.object {
32
+ | Some(obj) =>
33
+ switch obj->Dict.get("updatedAt") {
34
+ | Some(JSON.String(v)) => Some(v)
35
+ | _ =>
36
+ switch obj->Dict.get("createdAt") {
37
+ | Some(JSON.String(v)) => Some(v)
38
+ | _ => None
39
+ }
40
+ }
41
+ | None => None
42
+ }
43
+
44
+ // Monotonic ordering token. Seeded from the wall clock and never allowed to go
45
+ // backwards, so it keeps rising across process restarts — a client that held a
46
+ // value from a previous run won't reject everything the new run publishes.
47
+ //
48
+ // Monotonic, NOT consecutive: it is shared by every read model in the process,
49
+ // so one entity's values skip. That is deliberate — the DynamoDB relay reads its
50
+ // sequence off the stream record, which is equally sparse, and a dense counter
51
+ // would mean maintaining a version on every row (the analysis file has the cost).
52
+ // The client rule is "greater than what I hold", never "exactly one more".
53
+ let lastSequence = ref(0.0)
54
+
55
+ let nextSequence = (): string => {
56
+ let now = Date.now()
57
+ let next = now > lastSequence.contents ? now : lastSequence.contents +. 1.0
58
+ lastSequence := next
59
+ next->Float.toString
60
+ }
61
+
62
+ /** Build a state-change descriptor.
63
+ - `changeKind`: one of "Added" | "Updated" | "Removed". save() emits "Added"
64
+ when no visible row held the key and "Updated" otherwise; delete() emits
65
+ "Removed".
66
+ - `id`: entity key. Single-key projections: the partition-key value.
67
+ Composite projections: `partition ++ "-" ++ subKey`.
68
+ - `state`: the resulting row for save(); `None` for delete(), which has no new
69
+ row — the descriptor then omits both `state` and `sortKeyValue`.
70
+ - `seq`: monotonic ordering token, from `nextSequence`. */
71
+ let make = (~changeKind: string, ~id: string, ~state: option<JSON.t>, ~seq: string): JSON.t => {
72
+ let descriptor = Dict.make()
73
+ descriptor->Dict.set("changeKind", JSON.Encode.string(changeKind))
74
+ descriptor->Dict.set("id", JSON.Encode.string(id))
75
+ switch state->Option.flatMap(pickSortKeyValue) {
76
+ | Some(v) => descriptor->Dict.set("sortKeyValue", JSON.Encode.string(v))
77
+ | None => ()
78
+ }
79
+ descriptor->Dict.set("seq", JSON.Encode.string(seq))
80
+ switch state {
81
+ | Some(s) =>
82
+ let encoded = s->JSON.stringify
83
+ if encoded->String.length <= maxStateChars {
84
+ descriptor->Dict.set("state", s)
85
+ } else {
86
+ Console.warn(
87
+ `STATE_PAYLOAD_DOWNGRADED id=${id} chars=${encoded->String.length->Int.toString}`,
88
+ )
89
+ }
90
+ | None => ()
91
+ }
92
+ descriptor->JSON.Encode.object
93
+ }
@@ -0,0 +1,61 @@
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 Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+
6
+ function pickSortKeyValue(state) {
7
+ let obj = Stdlib_JSON.Decode.object(state);
8
+ if (obj === undefined) {
9
+ return;
10
+ }
11
+ let match = obj["updatedAt"];
12
+ if (typeof match === "string") {
13
+ return match;
14
+ }
15
+ let match$1 = obj["createdAt"];
16
+ if (typeof match$1 === "string") {
17
+ return match$1;
18
+ }
19
+ }
20
+
21
+ let lastSequence = {
22
+ contents: 0.0
23
+ };
24
+
25
+ function nextSequence() {
26
+ let now = Date.now();
27
+ let next = now > lastSequence.contents ? now : lastSequence.contents + 1.0;
28
+ lastSequence.contents = next;
29
+ return next.toString();
30
+ }
31
+
32
+ function make(changeKind, id, state, seq) {
33
+ let descriptor = {};
34
+ descriptor["changeKind"] = changeKind;
35
+ descriptor["id"] = id;
36
+ let v = Stdlib_Option.flatMap(state, pickSortKeyValue);
37
+ if (v !== undefined) {
38
+ descriptor["sortKeyValue"] = v;
39
+ }
40
+ descriptor["seq"] = seq;
41
+ if (state !== undefined) {
42
+ let encoded = JSON.stringify(state);
43
+ if (encoded.length <= 61440) {
44
+ descriptor["state"] = state;
45
+ } else {
46
+ console.warn(`STATE_PAYLOAD_DOWNGRADED id=` + id + ` chars=` + encoded.length.toString());
47
+ }
48
+ }
49
+ return descriptor;
50
+ }
51
+
52
+ let maxStateChars = 61440;
53
+
54
+ export {
55
+ maxStateChars,
56
+ pickSortKeyValue,
57
+ lastSequence,
58
+ nextSequence,
59
+ make,
60
+ }
61
+ /* No side effect */
@@ -9,8 +9,20 @@
9
9
 
10
10
  // Mint a same-origin `/{prefix}/{uuid}/{fileName}` ref: both the PUT target and the
11
11
  // stored value, mirroring the AWS presign ticket's shape.
12
- let mintRef = (~fileName: string): string =>
13
- `/${LocalObjectStore.defaultUploadPrefix}/${NodeCrypto.randomUUID()}/${fileName}`
12
+ //
13
+ // The prefix is the declaring store's (`{plugin}/{store}`), so an object carries
14
+ // the plugin that owns it in its own key — what lets a scoped reset attribute it,
15
+ // exactly as an S3 key's prefix does. A store no connected plugin declared falls
16
+ // back to the shared `uploads/` space rather than being refused: local plugins
17
+ // need not declare a store to upload during development, and refusing here would
18
+ // make dev stricter than deploy about something dev cannot check.
19
+ let mintRef = (~store: string, ~fileName: string): string => {
20
+ let prefix =
21
+ LocalObjectStore.storePrefix(~qualified=store)->Option.getOr(
22
+ LocalObjectStore.defaultUploadPrefix,
23
+ )
24
+ `/${prefix}/${NodeCrypto.randomUUID()}/${fileName}`
25
+ }
14
26
 
15
27
  // Release a ref: delete iff it sits under a served prefix; idempotent (deleting an
16
28
  // absent key still succeeds). Returns `(released, reason)`.
@@ -30,7 +42,8 @@ let register = (server: ReventlessGraphqlServer.GraphQL_ServerInstance.t): unit
30
42
  let obj = args->JSON.Decode.object->Option.getOr(Dict.make())
31
43
  let fileName =
32
44
  obj->Dict.get("fileName")->Option.flatMap(JSON.Decode.string)->Option.getOr("upload")
33
- let ref = mintRef(~fileName)
45
+ let store = obj->Dict.get("store")->Option.flatMap(JSON.Decode.string)->Option.getOr("")
46
+ let ref = mintRef(~store, ~fileName)
34
47
  Dict.fromArray([
35
48
  ("uploadUrl", JSON.Encode.string(ref)),
36
49
  ("storageRef", JSON.Encode.string(ref)),
@@ -3,11 +3,12 @@
3
3
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
4
  import * as Nodecrypto from "node:crypto";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
- import * as LocalObjectStore$ReventlessLocal from "./LocalObjectStore.res.mjs";
6
+ import * as LocalObjectStore$ReventlessLocal from "./ObjectStore/LocalObjectStore.res.mjs";
7
7
  import * as Platform_AdminApi$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_AdminApi.res.mjs";
8
8
 
9
- function mintRef(fileName) {
10
- return `/` + LocalObjectStore$ReventlessLocal.defaultUploadPrefix + `/` + Nodecrypto.randomUUID() + `/` + fileName;
9
+ function mintRef(store, fileName) {
10
+ let prefix = Stdlib_Option.getOr(LocalObjectStore$ReventlessLocal.storePrefix(store), LocalObjectStore$ReventlessLocal.defaultUploadPrefix);
11
+ return `/` + prefix + `/` + Nodecrypto.randomUUID() + `/` + fileName;
11
12
  }
12
13
 
13
14
  function release(storageRef) {
@@ -31,7 +32,8 @@ function register(server) {
31
32
  resolvers["Upload_Presign"] = async (_root, args, _ctx) => {
32
33
  let obj = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(args), {});
33
34
  let fileName = Stdlib_Option.getOr(Stdlib_Option.flatMap(obj["fileName"], Stdlib_JSON.Decode.string), "upload");
34
- let ref = mintRef(fileName);
35
+ let store = Stdlib_Option.getOr(Stdlib_Option.flatMap(obj["store"], Stdlib_JSON.Decode.string), "");
36
+ let ref = mintRef(store, fileName);
35
37
  return Object.fromEntries([
36
38
  [
37
39
  "uploadUrl",
@@ -0,0 +1,157 @@
1
+ // Object store dispatcher 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 objects live wherever the active storage
5
+ // backend keeps its state — the store follows the events rather than choosing
6
+ // its own durability:
7
+ // - a file-backed SQLite database → ObjectStoreStorage_FileSystem, writing
8
+ // beside the database (`./.reventless/objects`, `./.reventless/offload`)
9
+ // - Memory, `:memory:` SQLite, Postgres → ObjectStoreStorage_InMemory
10
+ // BackendState.getObjectStoreRoot makes that choice; the arms are consulted per
11
+ // call rather than at a `make`, since this store has no construction step and a
12
+ // suite may flip the backend between tests.
13
+ //
14
+ // Held outside EventLog/QueryDb storage because it is raw bytes, not event or
15
+ // query state — hence the `Local` prefix on the dispatcher, and `_InMemory` /
16
+ // `_FileSystem` on the arms (not `_Sqlite`: the durable arm is the filesystem,
17
+ // anchored to the database's directory).
18
+
19
+ type entry = {
20
+ bytes: NodeBuffer.t,
21
+ contentType: string,
22
+ }
23
+
24
+ // The prefix keys are minted under when no declared store claims them. Mirrors
25
+ // the AWS presign `SERVED_PREFIX` default; it stays served even once every store
26
+ // declares its own prefix, because refs already minted under it live in an
27
+ // append-only event log and must keep resolving.
28
+ let defaultUploadPrefix = "uploads"
29
+
30
+ // Prefixes the dev server serves at `/{prefix}/*` (PUT stores, GET reads).
31
+ // Seeded with the default upload prefix; declared stores add their own.
32
+ let servedPrefixes: ref<array<string>> = ref([defaultUploadPrefix])
33
+
34
+ let registerServedPrefix = (prefix: string): unit =>
35
+ if !(servedPrefixes.contents->Array.includes(prefix)) {
36
+ servedPrefixes.contents = servedPrefixes.contents->Array.concat([prefix])
37
+ }
38
+
39
+ // Declared object stores, `{plugin}.{store}` → the prefix its keys are rooted at
40
+ // (`StoreLayout.keyPrefixFor`). The deployed platform threads the same map into
41
+ // its presign service as `UPLOAD_STORES`; here the Platform fills it from every
42
+ // connected plugin's `requiredStores`.
43
+ //
44
+ // Rooting keys at the declaring store — rather than dropping every plugin's
45
+ // uploads into one `uploads/` space — is what makes an object attributable: a
46
+ // scoped wipe reads the plugin back off the key prefix, exactly as it does from
47
+ // an S3 key.
48
+ let declaredStores: dict<string> = Dict.make()
49
+
50
+ /** Where a declared store's objects sit locally: the deployed `{plugin}/{store}`
51
+ layout NESTED under the served `uploads/` prefix.
52
+
53
+ The nesting is not cosmetic. A dev UI is served by its own dev server and
54
+ reaches the platform through a proxy that forwards one path — `/uploads` — so a
55
+ ref minted outside it resolves against the UI server instead of the platform,
56
+ and the image silently renders as the SPA shell. Keeping every object under
57
+ `uploads/` means the serve path is a property of the platform, not something
58
+ each UI dev server has to be reconfigured to know. The `{plugin}/{store}`
59
+ segments still carry the attribution a scoped reset reads back off the key. */
60
+ let localPrefixFor = (~qualified: string): string =>
61
+ `${defaultUploadPrefix}/${ReventlessCore.StoreLayout.prefixOfQualified(qualified)}`
62
+
63
+ let registerStore = (~qualified: string, ~prefix: string): unit => {
64
+ declaredStores->Dict.set(qualified, prefix)
65
+ registerServedPrefix(prefix)
66
+ }
67
+
68
+ let storePrefix = (~qualified: string): option<string> => declaredStores->Dict.get(qualified)
69
+
70
+ let declaredStoreList = (): array<(string, string)> => declaredStores->Dict.toArray
71
+
72
+ // A request path is a served-object path when it sits under a registered served
73
+ // prefix with at least one key segment following. Returns the storage key (the
74
+ // path without its leading slash), or None for GraphQL / other paths.
75
+ //
76
+ // Matches the prefix as a path, not as a first segment: a declared store's
77
+ // prefix is `{plugin}/{store}` — two segments — while the default `uploads` is
78
+ // one, and both have to resolve through the same route.
79
+ //
80
+ // `..`, `.` and empty segments are refused here rather than inside the storage
81
+ // arms so that every caller — the HTTP routes and Upload_Release alike — agrees
82
+ // on which paths exist, whichever backend is active. Under the filesystem arm a
83
+ // key becomes a path, and `uploads/../../etc/passwd` would otherwise escape the
84
+ // store.
85
+ let servedKey = (path: string): option<string> => {
86
+ let trimmed = path->String.startsWith("/") ? path->String.slice(~start=1, ~end=path->String.length) : path
87
+ let segments = trimmed->String.split("/")
88
+ let underAPrefix =
89
+ servedPrefixes.contents->Array.some(p =>
90
+ p->String.length > 0 && trimmed->String.startsWith(p ++ "/") && trimmed->String.length > p->String.length + 1
91
+ )
92
+ if underAPrefix && segments->Array.every(seg => seg != "" && seg != "." && seg != "..") {
93
+ Some(trimmed)
94
+ } else {
95
+ None
96
+ }
97
+ }
98
+
99
+ let put = (~key: string, ~bytes: NodeBuffer.t, ~contentType: string): unit =>
100
+ switch BackendState.getObjectStoreRoot() {
101
+ | Some(root) => ObjectStoreStorage_FileSystem.put(~root, ~key, ~bytes, ~contentType)
102
+ | None => ObjectStoreStorage_InMemory.put(~key, ~bytes, ~contentType)
103
+ }
104
+
105
+ let get = (~key: string): option<entry> => {
106
+ let stored = switch BackendState.getObjectStoreRoot() {
107
+ | Some(root) => ObjectStoreStorage_FileSystem.get(~root, ~key)
108
+ | None => ObjectStoreStorage_InMemory.get(~key)
109
+ }
110
+ stored->Option.map(((bytes, contentType)) => {bytes, contentType})
111
+ }
112
+
113
+ // Remove a stored object. Idempotent — deleting an absent key is a no-op, matching
114
+ // the release contract (see docs/plans/done/upload-release-path.md, Step 3). The dev
115
+ // store has no identities or clock, so the release resolver enforces only the
116
+ // *shape* of the rule (key under a served prefix), not the identity/age conditions.
117
+ let delete = (~key: string): unit =>
118
+ switch BackendState.getObjectStoreRoot() {
119
+ | Some(root) => ObjectStoreStorage_FileSystem.delete(~root, ~key)
120
+ | None => ObjectStoreStorage_InMemory.delete(~key)
121
+ }
122
+
123
+ // ── Offload objects ───────────────────────────────────────────────────────
124
+ // Content-addressed deploy-time objects (`sha256/<hash>`), holding the large
125
+ // pluginDefinition fields the connect handshake carries by reference instead of
126
+ // inline. Deliberately a separate keyspace from the served objects: on AWS these
127
+ // live in a private bucket the ComponentDefinitions Lambda reads through the SDK,
128
+ // never behind a served route, so they must not become reachable over
129
+ // `/{prefix}/*` here either. Bytes are the value's JSON, kept as a string because
130
+ // that is what `Offload.resolve`'s `fetch` hands back.
131
+
132
+ let putOffload = (~key: string, ~bytes: string): unit =>
133
+ switch BackendState.getObjectStoreRoot() {
134
+ | Some(root) => ObjectStoreStorage_FileSystem.putOffload(~root, ~key, ~bytes)
135
+ | None => ObjectStoreStorage_InMemory.putOffload(~key, ~bytes)
136
+ }
137
+
138
+ let getOffload = (~key: string): option<string> =>
139
+ switch BackendState.getObjectStoreRoot() {
140
+ | Some(root) => ObjectStoreStorage_FileSystem.getOffload(~root, ~key)
141
+ | None => ObjectStoreStorage_InMemory.getOffload(~key)
142
+ }
143
+
144
+ // Clear stored objects and restore the default served prefix. Called between
145
+ // isolated test suites (from DomainGraphQL_Server.reset) and at Platform
146
+ // construction under `sqlite:…?reset`, where wiping the events has to wipe the
147
+ // bytes they reference too. Clears both arms: the backend is chosen before
148
+ // construction, but a suite that flips backends can leave state in either.
149
+ let reset = (): unit => {
150
+ ObjectStoreStorage_InMemory.reset()
151
+ switch BackendState.getObjectStoreRoot() {
152
+ | Some(root) => ObjectStoreStorage_FileSystem.reset(~root)
153
+ | None => ()
154
+ }
155
+ servedPrefixes.contents = [defaultUploadPrefix]
156
+ declaredStores->Dict.keysToArray->Array.forEach(k => declaredStores->Dict.delete(k))
157
+ }