@reventlessdev/reventless-local 3.0.0-alpha.200 → 3.0.0-alpha.202
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 +17 -0
- package/package.json +10 -9
- package/rescript.json +2 -1
- package/src/Platform.res +70 -37
- package/src/Platform.res.mjs +48 -34
- package/src/adapter/BackendState.res +13 -0
- package/src/adapter/BackendState.res.mjs +17 -1
- package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res.mjs +1 -1
- package/src/adapter/DomainGraphQL_Server.res +5 -5
- package/src/adapter/DomainGraphQL_Server.res.mjs +1 -1
- package/src/adapter/EventLog/LocalEventLogStorage.res.mjs +1 -1
- package/src/adapter/LocalBus.res +6 -46
- package/src/adapter/LocalBus.res.mjs +0 -29
- package/src/adapter/LocalStateChangeDescriptor.res +93 -0
- package/src/adapter/LocalStateChangeDescriptor.res.mjs +61 -0
- package/src/adapter/LocalUploadResolvers.res +16 -3
- package/src/adapter/LocalUploadResolvers.res.mjs +6 -4
- package/src/adapter/ObjectStore/LocalObjectStore.res +157 -0
- package/src/adapter/ObjectStore/LocalObjectStore.res.mjs +129 -0
- package/src/adapter/ObjectStore/ObjectStoreStorage_FileSystem.res +215 -0
- package/src/adapter/ObjectStore/ObjectStoreStorage_FileSystem.res.mjs +286 -0
- package/src/adapter/ObjectStore/ObjectStoreStorage_InMemory.res +33 -0
- package/src/adapter/ObjectStore/ObjectStoreStorage_InMemory.res.mjs +47 -0
- package/src/adapter/QueryDb/LocalQueryDbStorage.res.mjs +1 -1
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +4 -2
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +3 -3
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +4 -2
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +3 -3
- package/src/reset/LocalSeedReset.res +552 -0
- package/src/reset/LocalSeedReset.res.mjs +533 -0
- package/tests/adapter/BackendParityTest.res +51 -0
- package/tests/adapter/BackendParityTest.res.mjs +44 -0
- package/tests/adapter/GraphQL_SubscriptionResolversTest.res +6 -4
- package/tests/adapter/GraphQL_SubscriptionResolversTest.res.mjs +4 -3
- package/tests/adapter/ObjectStorePersistenceTest.res +243 -0
- package/tests/adapter/ObjectStorePersistenceTest.res.mjs +159 -0
- package/tests/components/eventlog/EventLogProvisioningSeamTest.res +110 -0
- package/tests/components/eventlog/EventLogProvisioningSeamTest.res.mjs +141 -0
- package/tests/reset/LocalSeedResetTest.res +252 -0
- package/tests/reset/LocalSeedResetTest.res.mjs +280 -0
- package/src/adapter/LocalObjectStore.res +0 -70
- package/src/adapter/LocalObjectStore.res.mjs +0 -60
|
@@ -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
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as StoreLayout$ReventlessCore from "@reventlessdev/reventless-core/src/util/StoreLayout.res.mjs";
|
|
6
|
+
import * as BackendState$ReventlessLocal from "../BackendState.res.mjs";
|
|
7
|
+
import * as ObjectStoreStorage_InMemory$ReventlessLocal from "./ObjectStoreStorage_InMemory.res.mjs";
|
|
8
|
+
import * as ObjectStoreStorage_FileSystem$ReventlessLocal from "./ObjectStoreStorage_FileSystem.res.mjs";
|
|
9
|
+
|
|
10
|
+
let defaultUploadPrefix = "uploads";
|
|
11
|
+
|
|
12
|
+
let servedPrefixes = {
|
|
13
|
+
contents: [defaultUploadPrefix]
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function registerServedPrefix(prefix) {
|
|
17
|
+
if (!servedPrefixes.contents.includes(prefix)) {
|
|
18
|
+
servedPrefixes.contents = servedPrefixes.contents.concat([prefix]);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let declaredStores = {};
|
|
24
|
+
|
|
25
|
+
function localPrefixFor(qualified) {
|
|
26
|
+
return defaultUploadPrefix + `/` + StoreLayout$ReventlessCore.prefixOfQualified(qualified);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function registerStore(qualified, prefix) {
|
|
30
|
+
declaredStores[qualified] = prefix;
|
|
31
|
+
registerServedPrefix(prefix);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function storePrefix(qualified) {
|
|
35
|
+
return declaredStores[qualified];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function declaredStoreList() {
|
|
39
|
+
return Object.entries(declaredStores);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function servedKey(path) {
|
|
43
|
+
let trimmed = path.startsWith("/") ? path.slice(1, path.length) : path;
|
|
44
|
+
let segments = trimmed.split("/");
|
|
45
|
+
let underAPrefix = servedPrefixes.contents.some(p => {
|
|
46
|
+
if (p.length > 0 && trimmed.startsWith(p + "/")) {
|
|
47
|
+
return trimmed.length > (p.length + 1 | 0);
|
|
48
|
+
} else {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
if (underAPrefix && segments.every(seg => seg !== "" && seg !== "." ? seg !== ".." : false)) {
|
|
53
|
+
return trimmed;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function put(key, bytes, contentType) {
|
|
58
|
+
let root = BackendState$ReventlessLocal.getObjectStoreRoot();
|
|
59
|
+
if (root !== undefined) {
|
|
60
|
+
return ObjectStoreStorage_FileSystem$ReventlessLocal.put(root, key, bytes, contentType);
|
|
61
|
+
} else {
|
|
62
|
+
return ObjectStoreStorage_InMemory$ReventlessLocal.put(key, bytes, contentType);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function get(key) {
|
|
67
|
+
let root = BackendState$ReventlessLocal.getObjectStoreRoot();
|
|
68
|
+
let stored = root !== undefined ? ObjectStoreStorage_FileSystem$ReventlessLocal.get(root, key) : ObjectStoreStorage_InMemory$ReventlessLocal.get(key);
|
|
69
|
+
return Stdlib_Option.map(stored, param => ({
|
|
70
|
+
bytes: param[0],
|
|
71
|
+
contentType: param[1]
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function $$delete(key) {
|
|
76
|
+
let root = BackendState$ReventlessLocal.getObjectStoreRoot();
|
|
77
|
+
if (root !== undefined) {
|
|
78
|
+
return ObjectStoreStorage_FileSystem$ReventlessLocal.$$delete(root, key);
|
|
79
|
+
} else {
|
|
80
|
+
return ObjectStoreStorage_InMemory$ReventlessLocal.$$delete(key);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function putOffload(key, bytes) {
|
|
85
|
+
let root = BackendState$ReventlessLocal.getObjectStoreRoot();
|
|
86
|
+
if (root !== undefined) {
|
|
87
|
+
return ObjectStoreStorage_FileSystem$ReventlessLocal.putOffload(root, key, bytes);
|
|
88
|
+
} else {
|
|
89
|
+
return ObjectStoreStorage_InMemory$ReventlessLocal.putOffload(key, bytes);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getOffload(key) {
|
|
94
|
+
let root = BackendState$ReventlessLocal.getObjectStoreRoot();
|
|
95
|
+
if (root !== undefined) {
|
|
96
|
+
return ObjectStoreStorage_FileSystem$ReventlessLocal.getOffload(root, key);
|
|
97
|
+
} else {
|
|
98
|
+
return ObjectStoreStorage_InMemory$ReventlessLocal.getOffload(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function reset() {
|
|
103
|
+
ObjectStoreStorage_InMemory$ReventlessLocal.reset();
|
|
104
|
+
let root = BackendState$ReventlessLocal.getObjectStoreRoot();
|
|
105
|
+
if (root !== undefined) {
|
|
106
|
+
ObjectStoreStorage_FileSystem$ReventlessLocal.reset(root);
|
|
107
|
+
}
|
|
108
|
+
servedPrefixes.contents = [defaultUploadPrefix];
|
|
109
|
+
Object.keys(declaredStores).forEach(k => Stdlib_Dict.$$delete(declaredStores, k));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export {
|
|
113
|
+
defaultUploadPrefix,
|
|
114
|
+
servedPrefixes,
|
|
115
|
+
registerServedPrefix,
|
|
116
|
+
declaredStores,
|
|
117
|
+
localPrefixFor,
|
|
118
|
+
registerStore,
|
|
119
|
+
storePrefix,
|
|
120
|
+
declaredStoreList,
|
|
121
|
+
servedKey,
|
|
122
|
+
put,
|
|
123
|
+
get,
|
|
124
|
+
$$delete,
|
|
125
|
+
putOffload,
|
|
126
|
+
getOffload,
|
|
127
|
+
reset,
|
|
128
|
+
}
|
|
129
|
+
/* BackendState-ReventlessLocal Not a pure module */
|