@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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
4
4
|
import * as LocalBus$ReventlessLocal from "../../src/adapter/LocalBus.res.mjs";
|
|
5
5
|
import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
|
|
6
|
+
import * as LocalStateChangeDescriptor$ReventlessLocal from "../../src/adapter/LocalStateChangeDescriptor.res.mjs";
|
|
6
7
|
import * as LocalGraphQL_SubscriptionResolvers$ReventlessLocal from "../../src/adapter/Api/LocalGraphQL_SubscriptionResolvers.res.mjs";
|
|
7
8
|
|
|
8
9
|
TestRunner$ReventlessLocal.setup();
|
|
@@ -68,7 +69,7 @@ globalThis.describe("LocalGraphQL_SubscriptionResolvers", () => {
|
|
|
68
69
|
"2026-05-19T12:00:00Z"
|
|
69
70
|
]
|
|
70
71
|
]);
|
|
71
|
-
let descriptor =
|
|
72
|
+
let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make("Updated", "prod-1", state, LocalStateChangeDescriptor$ReventlessLocal.nextSequence());
|
|
72
73
|
TestBus.publishStateChange("Product", descriptor);
|
|
73
74
|
let received = await consumerPromise;
|
|
74
75
|
if (received !== null) {
|
|
@@ -84,10 +85,10 @@ globalThis.describe("LocalGraphQL_SubscriptionResolvers", () => {
|
|
|
84
85
|
let iter = ps.subscribe("onCatalogProduct_stateChanged");
|
|
85
86
|
let consumerPromise = startConsumer(iter, 300);
|
|
86
87
|
await yieldTick();
|
|
87
|
-
let foreignDescriptor =
|
|
88
|
+
let foreignDescriptor = LocalStateChangeDescriptor$ReventlessLocal.make("Updated", "cat-1", Object.fromEntries([[
|
|
88
89
|
"id",
|
|
89
90
|
"cat-1"
|
|
90
|
-
]]));
|
|
91
|
+
]]), LocalStateChangeDescriptor$ReventlessLocal.nextSequence());
|
|
91
92
|
TestBus.publishStateChange("Category", foreignDescriptor);
|
|
92
93
|
let received = await consumerPromise;
|
|
93
94
|
globalThis.expect(received === null ? undefined : Primitive_option.some(received)).toEqual(undefined);
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// The local object store follows the active backend's durability: with a
|
|
2
|
+
// file-backed SQLite database the bytes land beside it on disk, so uploads and
|
|
3
|
+
// offloaded payloads survive the restart that their events survive; with Memory
|
|
4
|
+
// they stay in-process.
|
|
5
|
+
|
|
6
|
+
@@warning("-44")
|
|
7
|
+
|
|
8
|
+
open JestGlobals
|
|
9
|
+
|
|
10
|
+
let tempRoot = (): string =>
|
|
11
|
+
NodeFs.mkdtempSync(NodePath.join([NodeOs.tmpdir(), "reventless-objectstore-"]))
|
|
12
|
+
|
|
13
|
+
let bytes = NodeBuffer.fromStringUtf8
|
|
14
|
+
|
|
15
|
+
describe("ObjectStoreStorage_FileSystem", () => {
|
|
16
|
+
testSync("put then get round-trips the bytes and the content type", () => {
|
|
17
|
+
let root = tempRoot()
|
|
18
|
+
ObjectStoreStorage_FileSystem.put(
|
|
19
|
+
~root,
|
|
20
|
+
~key="uploads/abc/logo.svg",
|
|
21
|
+
~bytes=bytes("<svg/>"),
|
|
22
|
+
~contentType="image/svg+xml",
|
|
23
|
+
)
|
|
24
|
+
expect(
|
|
25
|
+
ObjectStoreStorage_FileSystem.get(~root, ~key="uploads/abc/logo.svg")->Option.map(
|
|
26
|
+
((got, contentType)) => (got->NodeBuffer.toStringUtf8, contentType),
|
|
27
|
+
),
|
|
28
|
+
)->toEqual(Some(("<svg/>", "image/svg+xml")))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
testSync("the key's slashes become directories under objects/", () => {
|
|
32
|
+
let root = tempRoot()
|
|
33
|
+
ObjectStoreStorage_FileSystem.put(
|
|
34
|
+
~root,
|
|
35
|
+
~key="uploads/abc/logo.svg",
|
|
36
|
+
~bytes=bytes("x"),
|
|
37
|
+
~contentType="image/svg+xml",
|
|
38
|
+
)
|
|
39
|
+
expect(
|
|
40
|
+
NodeFs.existsSync(NodePath.join([root, "objects", "uploads", "abc", "logo.svg"])),
|
|
41
|
+
)->toEqual(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
testSync("get returns None for a missing key", () => {
|
|
45
|
+
let root = tempRoot()
|
|
46
|
+
expect(ObjectStoreStorage_FileSystem.get(~root, ~key="uploads/none/x.svg"))->toEqual(None)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
testSync("get returns None for a key naming a directory rather than an object", () => {
|
|
50
|
+
let root = tempRoot()
|
|
51
|
+
ObjectStoreStorage_FileSystem.put(
|
|
52
|
+
~root,
|
|
53
|
+
~key="uploads/abc/logo.svg",
|
|
54
|
+
~bytes=bytes("x"),
|
|
55
|
+
~contentType="image/svg+xml",
|
|
56
|
+
)
|
|
57
|
+
expect(ObjectStoreStorage_FileSystem.get(~root, ~key="uploads/abc"))->toEqual(None)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
testSync("delete removes the object, its content type, and the directory it emptied", () => {
|
|
61
|
+
let root = tempRoot()
|
|
62
|
+
ObjectStoreStorage_FileSystem.put(
|
|
63
|
+
~root,
|
|
64
|
+
~key="uploads/abc/logo.svg",
|
|
65
|
+
~bytes=bytes("x"),
|
|
66
|
+
~contentType="image/svg+xml",
|
|
67
|
+
)
|
|
68
|
+
ObjectStoreStorage_FileSystem.delete(~root, ~key="uploads/abc/logo.svg")
|
|
69
|
+
expect(ObjectStoreStorage_FileSystem.get(~root, ~key="uploads/abc/logo.svg"))->toEqual(None)
|
|
70
|
+
expect(NodeFs.existsSync(NodePath.join([root, "objects", "uploads", "abc"])))->toEqual(false)
|
|
71
|
+
expect(NodeFs.existsSync(NodePath.join([root, "object-meta", "uploads", "abc"])))->toEqual(
|
|
72
|
+
false,
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
testSync("delete of an absent key is a no-op", () => {
|
|
77
|
+
let root = tempRoot()
|
|
78
|
+
ObjectStoreStorage_FileSystem.delete(~root, ~key="uploads/abc/gone.svg")
|
|
79
|
+
expect(ObjectStoreStorage_FileSystem.get(~root, ~key="uploads/abc/gone.svg"))->toEqual(None)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
testSync("offload payloads round-trip in their own keyspace", () => {
|
|
83
|
+
let root = tempRoot()
|
|
84
|
+
ObjectStoreStorage_FileSystem.putOffload(~root, ~key="sha256/deadbeef", ~bytes=`{"a":1}`)
|
|
85
|
+
expect(ObjectStoreStorage_FileSystem.getOffload(~root, ~key="sha256/deadbeef"))->toEqual(
|
|
86
|
+
Some(`{"a":1}`),
|
|
87
|
+
)
|
|
88
|
+
// Not reachable as a served object — the two trees are separate.
|
|
89
|
+
expect(ObjectStoreStorage_FileSystem.get(~root, ~key="sha256/deadbeef"))->toEqual(None)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
testSync("a traversing key is refused rather than written outside the root", () => {
|
|
93
|
+
let root = tempRoot()
|
|
94
|
+
ObjectStoreStorage_FileSystem.put(
|
|
95
|
+
~root,
|
|
96
|
+
~key="uploads/../../escaped.svg",
|
|
97
|
+
~bytes=bytes("x"),
|
|
98
|
+
~contentType="image/svg+xml",
|
|
99
|
+
)
|
|
100
|
+
// `objects/uploads/../../escaped.svg` resolves to `<root>/escaped.svg`.
|
|
101
|
+
expect(NodeFs.existsSync(NodePath.join([root, "escaped.svg"])))->toEqual(false)
|
|
102
|
+
expect(ObjectStoreStorage_FileSystem.get(~root, ~key="uploads/../../escaped.svg"))->toEqual(
|
|
103
|
+
None,
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
testSync("reset clears the store's trees and leaves the rest of the root alone", () => {
|
|
108
|
+
let root = tempRoot()
|
|
109
|
+
NodeFs.writeFileSync(NodePath.join([root, "local.db"]), "not a real database")
|
|
110
|
+
ObjectStoreStorage_FileSystem.put(
|
|
111
|
+
~root,
|
|
112
|
+
~key="uploads/abc/logo.svg",
|
|
113
|
+
~bytes=bytes("x"),
|
|
114
|
+
~contentType="image/svg+xml",
|
|
115
|
+
)
|
|
116
|
+
ObjectStoreStorage_FileSystem.putOffload(~root, ~key="sha256/deadbeef", ~bytes="{}")
|
|
117
|
+
|
|
118
|
+
ObjectStoreStorage_FileSystem.reset(~root)
|
|
119
|
+
|
|
120
|
+
expect(ObjectStoreStorage_FileSystem.get(~root, ~key="uploads/abc/logo.svg"))->toEqual(None)
|
|
121
|
+
expect(ObjectStoreStorage_FileSystem.getOffload(~root, ~key="sha256/deadbeef"))->toEqual(None)
|
|
122
|
+
expect(NodeFs.existsSync(NodePath.join([root, "local.db"])))->toEqual(true)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe("LocalObjectStore backend dispatch", () => {
|
|
127
|
+
// Restore the module-global backend so a later assertion in this file (and the
|
|
128
|
+
// suite's own default) sees Memory rather than whichever db a test opened.
|
|
129
|
+
afterEach(() => BackendState.setMemory())
|
|
130
|
+
|
|
131
|
+
testSync("a file-backed SQLite backend puts the bytes on disk beside the database", () => {
|
|
132
|
+
let root = tempRoot()
|
|
133
|
+
let path = NodePath.join([root, "local.db"])
|
|
134
|
+
BackendState.setSqlite(~db=SqliteDriver.openDb(~path), ~path)
|
|
135
|
+
|
|
136
|
+
LocalObjectStore.put(~key="uploads/abc/logo.svg", ~bytes=bytes("x"), ~contentType="image/png")
|
|
137
|
+
|
|
138
|
+
expect(
|
|
139
|
+
NodeFs.existsSync(NodePath.join([root, "objects", "uploads", "abc", "logo.svg"])),
|
|
140
|
+
)->toEqual(true)
|
|
141
|
+
expect(
|
|
142
|
+
LocalObjectStore.get(~key="uploads/abc/logo.svg")->Option.map(e => e.contentType),
|
|
143
|
+
)->toEqual(Some("image/png"))
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
testSync("an object stored on disk outlives the process that stored it", () => {
|
|
147
|
+
let root = tempRoot()
|
|
148
|
+
let path = NodePath.join([root, "local.db"])
|
|
149
|
+
|
|
150
|
+
// First "process": store, then drop every trace of it from memory.
|
|
151
|
+
BackendState.setSqlite(~db=SqliteDriver.openDb(~path), ~path)
|
|
152
|
+
LocalObjectStore.put(
|
|
153
|
+
~key="uploads/abc/logo.svg",
|
|
154
|
+
~bytes=bytes("kept"),
|
|
155
|
+
~contentType="image/png",
|
|
156
|
+
)
|
|
157
|
+
LocalObjectStore.putOffload(~key="sha256/cafe", ~bytes=`{"structure":true}`)
|
|
158
|
+
BackendState.setMemory()
|
|
159
|
+
ObjectStoreStorage_InMemory.reset()
|
|
160
|
+
|
|
161
|
+
// Second "process": same database file, same directory.
|
|
162
|
+
BackendState.setSqlite(~db=SqliteDriver.openDb(~path), ~path)
|
|
163
|
+
expect(
|
|
164
|
+
LocalObjectStore.get(~key="uploads/abc/logo.svg")->Option.map(e => e.contentType),
|
|
165
|
+
)->toEqual(Some("image/png"))
|
|
166
|
+
expect(LocalObjectStore.getOffload(~key="sha256/cafe"))->toEqual(Some(`{"structure":true}`))
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
testSync("a `:memory:` SQLite backend keeps objects in process, writing nothing", () => {
|
|
170
|
+
BackendState.setSqlite(~db=SqliteDriver.openDb(~path=":memory:"), ~path=":memory:")
|
|
171
|
+
expect(BackendState.getObjectStoreRoot())->toEqual(None)
|
|
172
|
+
|
|
173
|
+
LocalObjectStore.put(~key="uploads/abc/logo.svg", ~bytes=bytes("x"), ~contentType="image/png")
|
|
174
|
+
expect(LocalObjectStore.get(~key="uploads/abc/logo.svg")->Option.isSome)->toEqual(true)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
testSync("the Memory backend keeps objects in process", () => {
|
|
178
|
+
BackendState.setMemory()
|
|
179
|
+
expect(BackendState.getObjectStoreRoot())->toEqual(None)
|
|
180
|
+
|
|
181
|
+
LocalObjectStore.put(~key="uploads/abc/mem.svg", ~bytes=bytes("x"), ~contentType="image/png")
|
|
182
|
+
expect(LocalObjectStore.get(~key="uploads/abc/mem.svg")->Option.isSome)->toEqual(true)
|
|
183
|
+
LocalObjectStore.delete(~key="uploads/abc/mem.svg")
|
|
184
|
+
expect(LocalObjectStore.get(~key="uploads/abc/mem.svg"))->toEqual(None)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
testSync("servedKey refuses a path that would climb out of the store", () => {
|
|
188
|
+
expect(LocalObjectStore.servedKey("/uploads/../../etc/passwd"))->toEqual(None)
|
|
189
|
+
expect(LocalObjectStore.servedKey("/uploads/abc/logo.svg"))->toEqual(
|
|
190
|
+
Some("uploads/abc/logo.svg"),
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe("declared store prefixes", () => {
|
|
196
|
+
afterEach(() => LocalObjectStore.reset())
|
|
197
|
+
|
|
198
|
+
testSync("a declared store mints under uploads/{plugin}/{store}", () => {
|
|
199
|
+
LocalObjectStore.registerStore(
|
|
200
|
+
~qualified="Catalog.productImages",
|
|
201
|
+
~prefix=LocalObjectStore.localPrefixFor(~qualified="Catalog.productImages"),
|
|
202
|
+
)
|
|
203
|
+
let ref = LocalUploadResolvers.mintRef(~store="Catalog.productImages", ~fileName="logo.svg")
|
|
204
|
+
expect(ref->String.startsWith("/uploads/Catalog/productImages/"))->toEqual(true)
|
|
205
|
+
expect(ref->String.endsWith("/logo.svg"))->toEqual(true)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
testSync("an undeclared store falls back to the shared uploads prefix", () => {
|
|
209
|
+
let ref = LocalUploadResolvers.mintRef(~store="Nobody.knows", ~fileName="logo.svg")
|
|
210
|
+
expect(ref->String.startsWith("/uploads/"))->toEqual(true)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
testSync("a store's objects stay under the served uploads prefix", () => {
|
|
214
|
+
LocalObjectStore.registerStore(
|
|
215
|
+
~qualified="Catalog.productImages",
|
|
216
|
+
~prefix="uploads/Catalog/productImages",
|
|
217
|
+
)
|
|
218
|
+
expect(LocalObjectStore.servedKey("/uploads/Catalog/productImages/abc/logo.svg"))->toEqual(
|
|
219
|
+
Some("uploads/Catalog/productImages/abc/logo.svg"),
|
|
220
|
+
)
|
|
221
|
+
// Nesting the store under `uploads/` is what keeps a UI dev server's single
|
|
222
|
+
// `/uploads` proxy hop enough to reach it — a sibling top-level prefix would
|
|
223
|
+
// resolve against the UI server instead of the platform.
|
|
224
|
+
expect(LocalObjectStore.servedKey("/Catalog/productImages/abc/logo.svg"))->toEqual(None)
|
|
225
|
+
expect(LocalObjectStore.servedKey("/Ordering/invoices/abc/x.pdf"))->toEqual(None)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
testSync("two plugins declaring the same store name stay apart", () => {
|
|
229
|
+
["Catalog.images", "Ordering.images"]->Array.forEach(
|
|
230
|
+
q =>
|
|
231
|
+
LocalObjectStore.registerStore(
|
|
232
|
+
~qualified=q,
|
|
233
|
+
~prefix=LocalObjectStore.localPrefixFor(~qualified=q),
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
expect(LocalObjectStore.storePrefix(~qualified="Catalog.images"))->toEqual(
|
|
237
|
+
Some("uploads/Catalog/images"),
|
|
238
|
+
)
|
|
239
|
+
expect(LocalObjectStore.storePrefix(~qualified="Ordering.images"))->toEqual(
|
|
240
|
+
Some("uploads/Ordering/images"),
|
|
241
|
+
)
|
|
242
|
+
})
|
|
243
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Nodefs from "node:fs";
|
|
4
|
+
import * as Nodeos from "node:os";
|
|
5
|
+
import * as Nodepath from "node:path";
|
|
6
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
|
+
import * as BackendState$ReventlessLocal from "../../src/adapter/BackendState.res.mjs";
|
|
8
|
+
import * as SqliteDriver$ReventlessLocal from "../../src/adapter/SqliteDriver.res.mjs";
|
|
9
|
+
import * as LocalObjectStore$ReventlessLocal from "../../src/adapter/ObjectStore/LocalObjectStore.res.mjs";
|
|
10
|
+
import * as LocalUploadResolvers$ReventlessLocal from "../../src/adapter/LocalUploadResolvers.res.mjs";
|
|
11
|
+
import * as ObjectStoreStorage_InMemory$ReventlessLocal from "../../src/adapter/ObjectStore/ObjectStoreStorage_InMemory.res.mjs";
|
|
12
|
+
import * as ObjectStoreStorage_FileSystem$ReventlessLocal from "../../src/adapter/ObjectStore/ObjectStoreStorage_FileSystem.res.mjs";
|
|
13
|
+
|
|
14
|
+
function tempRoot() {
|
|
15
|
+
return Nodefs.mkdtempSync(Nodepath.join(Nodeos.tmpdir(), "reventless-objectstore-"));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function bytes(prim) {
|
|
19
|
+
return Buffer.from(prim, "utf8");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
globalThis.describe("ObjectStoreStorage_FileSystem", () => {
|
|
23
|
+
globalThis.test("put then get round-trips the bytes and the content type", () => {
|
|
24
|
+
let root = tempRoot();
|
|
25
|
+
ObjectStoreStorage_FileSystem$ReventlessLocal.put(root, "uploads/abc/logo.svg", Buffer.from("<svg/>", "utf8"), "image/svg+xml");
|
|
26
|
+
globalThis.expect(Stdlib_Option.map(ObjectStoreStorage_FileSystem$ReventlessLocal.get(root, "uploads/abc/logo.svg"), param => [
|
|
27
|
+
param[0].toString("utf8"),
|
|
28
|
+
param[1]
|
|
29
|
+
])).toEqual([
|
|
30
|
+
"<svg/>",
|
|
31
|
+
"image/svg+xml"
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
globalThis.test("the key's slashes become directories under objects/", () => {
|
|
35
|
+
let root = tempRoot();
|
|
36
|
+
ObjectStoreStorage_FileSystem$ReventlessLocal.put(root, "uploads/abc/logo.svg", Buffer.from("x", "utf8"), "image/svg+xml");
|
|
37
|
+
globalThis.expect(Nodefs.existsSync(Nodepath.join(root, "objects", "uploads", "abc", "logo.svg"))).toEqual(true);
|
|
38
|
+
});
|
|
39
|
+
globalThis.test("get returns None for a missing key", () => {
|
|
40
|
+
let root = tempRoot();
|
|
41
|
+
globalThis.expect(ObjectStoreStorage_FileSystem$ReventlessLocal.get(root, "uploads/none/x.svg")).toEqual(undefined);
|
|
42
|
+
});
|
|
43
|
+
globalThis.test("get returns None for a key naming a directory rather than an object", () => {
|
|
44
|
+
let root = tempRoot();
|
|
45
|
+
ObjectStoreStorage_FileSystem$ReventlessLocal.put(root, "uploads/abc/logo.svg", Buffer.from("x", "utf8"), "image/svg+xml");
|
|
46
|
+
globalThis.expect(ObjectStoreStorage_FileSystem$ReventlessLocal.get(root, "uploads/abc")).toEqual(undefined);
|
|
47
|
+
});
|
|
48
|
+
globalThis.test("delete removes the object, its content type, and the directory it emptied", () => {
|
|
49
|
+
let root = tempRoot();
|
|
50
|
+
ObjectStoreStorage_FileSystem$ReventlessLocal.put(root, "uploads/abc/logo.svg", Buffer.from("x", "utf8"), "image/svg+xml");
|
|
51
|
+
ObjectStoreStorage_FileSystem$ReventlessLocal.$$delete(root, "uploads/abc/logo.svg");
|
|
52
|
+
globalThis.expect(ObjectStoreStorage_FileSystem$ReventlessLocal.get(root, "uploads/abc/logo.svg")).toEqual(undefined);
|
|
53
|
+
globalThis.expect(Nodefs.existsSync(Nodepath.join(root, "objects", "uploads", "abc"))).toEqual(false);
|
|
54
|
+
globalThis.expect(Nodefs.existsSync(Nodepath.join(root, "object-meta", "uploads", "abc"))).toEqual(false);
|
|
55
|
+
});
|
|
56
|
+
globalThis.test("delete of an absent key is a no-op", () => {
|
|
57
|
+
let root = tempRoot();
|
|
58
|
+
ObjectStoreStorage_FileSystem$ReventlessLocal.$$delete(root, "uploads/abc/gone.svg");
|
|
59
|
+
globalThis.expect(ObjectStoreStorage_FileSystem$ReventlessLocal.get(root, "uploads/abc/gone.svg")).toEqual(undefined);
|
|
60
|
+
});
|
|
61
|
+
globalThis.test("offload payloads round-trip in their own keyspace", () => {
|
|
62
|
+
let root = tempRoot();
|
|
63
|
+
ObjectStoreStorage_FileSystem$ReventlessLocal.putOffload(root, "sha256/deadbeef", `{"a":1}`);
|
|
64
|
+
globalThis.expect(ObjectStoreStorage_FileSystem$ReventlessLocal.getOffload(root, "sha256/deadbeef")).toEqual(`{"a":1}`);
|
|
65
|
+
globalThis.expect(ObjectStoreStorage_FileSystem$ReventlessLocal.get(root, "sha256/deadbeef")).toEqual(undefined);
|
|
66
|
+
});
|
|
67
|
+
globalThis.test("a traversing key is refused rather than written outside the root", () => {
|
|
68
|
+
let root = tempRoot();
|
|
69
|
+
ObjectStoreStorage_FileSystem$ReventlessLocal.put(root, "uploads/../../escaped.svg", Buffer.from("x", "utf8"), "image/svg+xml");
|
|
70
|
+
globalThis.expect(Nodefs.existsSync(Nodepath.join(root, "escaped.svg"))).toEqual(false);
|
|
71
|
+
globalThis.expect(ObjectStoreStorage_FileSystem$ReventlessLocal.get(root, "uploads/../../escaped.svg")).toEqual(undefined);
|
|
72
|
+
});
|
|
73
|
+
globalThis.test("reset clears the store's trees and leaves the rest of the root alone", () => {
|
|
74
|
+
let root = tempRoot();
|
|
75
|
+
Nodefs.writeFileSync(Nodepath.join(root, "local.db"), "not a real database", "utf8");
|
|
76
|
+
ObjectStoreStorage_FileSystem$ReventlessLocal.put(root, "uploads/abc/logo.svg", Buffer.from("x", "utf8"), "image/svg+xml");
|
|
77
|
+
ObjectStoreStorage_FileSystem$ReventlessLocal.putOffload(root, "sha256/deadbeef", "{}");
|
|
78
|
+
ObjectStoreStorage_FileSystem$ReventlessLocal.reset(root);
|
|
79
|
+
globalThis.expect(ObjectStoreStorage_FileSystem$ReventlessLocal.get(root, "uploads/abc/logo.svg")).toEqual(undefined);
|
|
80
|
+
globalThis.expect(ObjectStoreStorage_FileSystem$ReventlessLocal.getOffload(root, "sha256/deadbeef")).toEqual(undefined);
|
|
81
|
+
globalThis.expect(Nodefs.existsSync(Nodepath.join(root, "local.db"))).toEqual(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
globalThis.describe("LocalObjectStore backend dispatch", () => {
|
|
86
|
+
globalThis.afterEach(() => BackendState$ReventlessLocal.setMemory());
|
|
87
|
+
globalThis.test("a file-backed SQLite backend puts the bytes on disk beside the database", () => {
|
|
88
|
+
let root = tempRoot();
|
|
89
|
+
let path = Nodepath.join(root, "local.db");
|
|
90
|
+
BackendState$ReventlessLocal.setSqlite(SqliteDriver$ReventlessLocal.openDb(path), path);
|
|
91
|
+
LocalObjectStore$ReventlessLocal.put("uploads/abc/logo.svg", Buffer.from("x", "utf8"), "image/png");
|
|
92
|
+
globalThis.expect(Nodefs.existsSync(Nodepath.join(root, "objects", "uploads", "abc", "logo.svg"))).toEqual(true);
|
|
93
|
+
globalThis.expect(Stdlib_Option.map(LocalObjectStore$ReventlessLocal.get("uploads/abc/logo.svg"), e => e.contentType)).toEqual("image/png");
|
|
94
|
+
});
|
|
95
|
+
globalThis.test("an object stored on disk outlives the process that stored it", () => {
|
|
96
|
+
let root = tempRoot();
|
|
97
|
+
let path = Nodepath.join(root, "local.db");
|
|
98
|
+
BackendState$ReventlessLocal.setSqlite(SqliteDriver$ReventlessLocal.openDb(path), path);
|
|
99
|
+
LocalObjectStore$ReventlessLocal.put("uploads/abc/logo.svg", Buffer.from("kept", "utf8"), "image/png");
|
|
100
|
+
LocalObjectStore$ReventlessLocal.putOffload("sha256/cafe", `{"structure":true}`);
|
|
101
|
+
BackendState$ReventlessLocal.setMemory();
|
|
102
|
+
ObjectStoreStorage_InMemory$ReventlessLocal.reset();
|
|
103
|
+
BackendState$ReventlessLocal.setSqlite(SqliteDriver$ReventlessLocal.openDb(path), path);
|
|
104
|
+
globalThis.expect(Stdlib_Option.map(LocalObjectStore$ReventlessLocal.get("uploads/abc/logo.svg"), e => e.contentType)).toEqual("image/png");
|
|
105
|
+
globalThis.expect(LocalObjectStore$ReventlessLocal.getOffload("sha256/cafe")).toEqual(`{"structure":true}`);
|
|
106
|
+
});
|
|
107
|
+
globalThis.test("a `:memory:` SQLite backend keeps objects in process, writing nothing", () => {
|
|
108
|
+
BackendState$ReventlessLocal.setSqlite(SqliteDriver$ReventlessLocal.openDb(":memory:"), ":memory:");
|
|
109
|
+
globalThis.expect(BackendState$ReventlessLocal.getObjectStoreRoot()).toEqual(undefined);
|
|
110
|
+
LocalObjectStore$ReventlessLocal.put("uploads/abc/logo.svg", Buffer.from("x", "utf8"), "image/png");
|
|
111
|
+
globalThis.expect(Stdlib_Option.isSome(LocalObjectStore$ReventlessLocal.get("uploads/abc/logo.svg"))).toEqual(true);
|
|
112
|
+
});
|
|
113
|
+
globalThis.test("the Memory backend keeps objects in process", () => {
|
|
114
|
+
BackendState$ReventlessLocal.setMemory();
|
|
115
|
+
globalThis.expect(BackendState$ReventlessLocal.getObjectStoreRoot()).toEqual(undefined);
|
|
116
|
+
LocalObjectStore$ReventlessLocal.put("uploads/abc/mem.svg", Buffer.from("x", "utf8"), "image/png");
|
|
117
|
+
globalThis.expect(Stdlib_Option.isSome(LocalObjectStore$ReventlessLocal.get("uploads/abc/mem.svg"))).toEqual(true);
|
|
118
|
+
LocalObjectStore$ReventlessLocal.$$delete("uploads/abc/mem.svg");
|
|
119
|
+
globalThis.expect(LocalObjectStore$ReventlessLocal.get("uploads/abc/mem.svg")).toEqual(undefined);
|
|
120
|
+
});
|
|
121
|
+
globalThis.test("servedKey refuses a path that would climb out of the store", () => {
|
|
122
|
+
globalThis.expect(LocalObjectStore$ReventlessLocal.servedKey("/uploads/../../etc/passwd")).toEqual(undefined);
|
|
123
|
+
globalThis.expect(LocalObjectStore$ReventlessLocal.servedKey("/uploads/abc/logo.svg")).toEqual("uploads/abc/logo.svg");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
globalThis.describe("declared store prefixes", () => {
|
|
128
|
+
globalThis.afterEach(() => LocalObjectStore$ReventlessLocal.reset());
|
|
129
|
+
globalThis.test("a declared store mints under uploads/{plugin}/{store}", () => {
|
|
130
|
+
LocalObjectStore$ReventlessLocal.registerStore("Catalog.productImages", LocalObjectStore$ReventlessLocal.localPrefixFor("Catalog.productImages"));
|
|
131
|
+
let ref = LocalUploadResolvers$ReventlessLocal.mintRef("Catalog.productImages", "logo.svg");
|
|
132
|
+
globalThis.expect(ref.startsWith("/uploads/Catalog/productImages/")).toEqual(true);
|
|
133
|
+
globalThis.expect(ref.endsWith("/logo.svg")).toEqual(true);
|
|
134
|
+
});
|
|
135
|
+
globalThis.test("an undeclared store falls back to the shared uploads prefix", () => {
|
|
136
|
+
let ref = LocalUploadResolvers$ReventlessLocal.mintRef("Nobody.knows", "logo.svg");
|
|
137
|
+
globalThis.expect(ref.startsWith("/uploads/")).toEqual(true);
|
|
138
|
+
});
|
|
139
|
+
globalThis.test("a store's objects stay under the served uploads prefix", () => {
|
|
140
|
+
LocalObjectStore$ReventlessLocal.registerStore("Catalog.productImages", "uploads/Catalog/productImages");
|
|
141
|
+
globalThis.expect(LocalObjectStore$ReventlessLocal.servedKey("/uploads/Catalog/productImages/abc/logo.svg")).toEqual("uploads/Catalog/productImages/abc/logo.svg");
|
|
142
|
+
globalThis.expect(LocalObjectStore$ReventlessLocal.servedKey("/Catalog/productImages/abc/logo.svg")).toEqual(undefined);
|
|
143
|
+
globalThis.expect(LocalObjectStore$ReventlessLocal.servedKey("/Ordering/invoices/abc/x.pdf")).toEqual(undefined);
|
|
144
|
+
});
|
|
145
|
+
globalThis.test("two plugins declaring the same store name stay apart", () => {
|
|
146
|
+
[
|
|
147
|
+
"Catalog.images",
|
|
148
|
+
"Ordering.images"
|
|
149
|
+
].forEach(q => LocalObjectStore$ReventlessLocal.registerStore(q, LocalObjectStore$ReventlessLocal.localPrefixFor(q)));
|
|
150
|
+
globalThis.expect(LocalObjectStore$ReventlessLocal.storePrefix("Catalog.images")).toEqual("uploads/Catalog/images");
|
|
151
|
+
globalThis.expect(LocalObjectStore$ReventlessLocal.storePrefix("Ordering.images")).toEqual("uploads/Ordering/images");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
export {
|
|
156
|
+
tempRoot,
|
|
157
|
+
bytes,
|
|
158
|
+
}
|
|
159
|
+
/* Not a pure module */
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// End-to-end check that the EventLogProvisioning seam fires from the real
|
|
2
|
+
// builders, not just from its own registry: a backend registered before the
|
|
3
|
+
// build sees both log styles as they are provisioned, through the local
|
|
4
|
+
// in-memory storage adapters.
|
|
5
|
+
//
|
|
6
|
+
// Registration and construction happen at module load — the seam fires during
|
|
7
|
+
// `Component.make`'s construct, so a backend registered inside a `test` body
|
|
8
|
+
// would arrive too late. The assertions then read what was recorded.
|
|
9
|
+
|
|
10
|
+
open JestGlobals
|
|
11
|
+
|
|
12
|
+
type record = {
|
|
13
|
+
logStyle: ReventlessCore.EventLogProvisioning.logStyle,
|
|
14
|
+
name: string,
|
|
15
|
+
owner: option<ReventlessCore.ResourceAttribution.owner>,
|
|
16
|
+
plugin: option<string>,
|
|
17
|
+
resourceCount: int,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let provisioned: array<record> = []
|
|
21
|
+
|
|
22
|
+
module Recorder: ReventlessCore.EventLogProvisioning.Backend = {
|
|
23
|
+
let onProvisioned = (~logStyle, ~name, ~owner, ~plugin, ~platform as _, ~resources, ~opts as _) =>
|
|
24
|
+
provisioned->Array.push({
|
|
25
|
+
logStyle,
|
|
26
|
+
name,
|
|
27
|
+
owner,
|
|
28
|
+
plugin,
|
|
29
|
+
resourceCount: resources->Array.length,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
ReventlessCore.EventLogProvisioning.use(module(Recorder: ReventlessCore.EventLogProvisioning.Backend))
|
|
34
|
+
|
|
35
|
+
module Bus = LocalBus.Make()
|
|
36
|
+
|
|
37
|
+
let _ = TestRunner.setup()
|
|
38
|
+
|
|
39
|
+
// ── Classic log ──────────────────────────────────────────────
|
|
40
|
+
module SeamItemSpec = {
|
|
41
|
+
module Id = Reventless.Id.StringPure
|
|
42
|
+
let name = "SeamItemEventLog"
|
|
43
|
+
|
|
44
|
+
@schema
|
|
45
|
+
type event = | SeamItemCreated({name: string})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module ClassicMaker = ReventlessCore.EventLog_Builder.Make(
|
|
49
|
+
SeamItemSpec,
|
|
50
|
+
EventLogStorage_InMemory,
|
|
51
|
+
LocalEventTopicPublisher.Make(Bus),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// The plugin builder publishes this context around a plugin's construct; entering
|
|
55
|
+
// it here is what a real plugin build does, so the seam should carry the names.
|
|
56
|
+
let previousContext = ReventlessCore.ResourceAttribution.enter(~platform="seam-platform", ~plugin="SeamPlugin")
|
|
57
|
+
|
|
58
|
+
let classicLog = ClassicMaker.make(
|
|
59
|
+
~name="SeamItem",
|
|
60
|
+
~owner={kind: ReventlessCore.ComponentType.Aggregate, name: "SeamItem"},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
// ── DCB log ──────────────────────────────────────────────────
|
|
64
|
+
module DcbMaker = DcbEventLog_Builder.Make(Bus)
|
|
65
|
+
|
|
66
|
+
let dcbLog = DcbMaker.make(
|
|
67
|
+
~name="SeamCatalog",
|
|
68
|
+
~partitionTag=Reventless.DcbTag.Simple({key: "id"}),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
ReventlessCore.ResourceAttribution.restore(previousContext)
|
|
72
|
+
|
|
73
|
+
describe("EventLogProvisioning seam, driven by the real builders", () => {
|
|
74
|
+
testSync("fires once per log, for both styles", () => {
|
|
75
|
+
expect(provisioned->Array.map(r => (r.logStyle, r.name)))->toEqual([
|
|
76
|
+
(ReventlessCore.EventLogProvisioning.Classic, "SeamItemEventLog"),
|
|
77
|
+
(ReventlessCore.EventLogProvisioning.Dcb, "SeamCatalogDcbEventLog"),
|
|
78
|
+
])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
testSync("carries the owning element: the aggregate for classic, the plugin for DCB", () => {
|
|
82
|
+
expect(provisioned->Array.map(r => r.owner))->toEqual([
|
|
83
|
+
Some(({kind: ReventlessCore.ComponentType.Aggregate, name: "SeamItem"}: ReventlessCore.ResourceAttribution.owner)),
|
|
84
|
+
Some(({kind: ReventlessCore.ComponentType.Plugin, name: "SeamCatalog"}: ReventlessCore.ResourceAttribution.owner)),
|
|
85
|
+
])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
testSync("carries the ambient plugin attribution the builder published", () => {
|
|
89
|
+
expect(provisioned->Array.map(r => r.plugin))->toEqual([
|
|
90
|
+
Some("SeamPlugin"),
|
|
91
|
+
Some("SeamPlugin"),
|
|
92
|
+
])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
testSync("carries the adapter's resources — empty for in-memory storage", () => {
|
|
96
|
+
// In-memory logs have nothing attachable; the seam still fires, and a backend
|
|
97
|
+
// that only implements a provider arm ignores them.
|
|
98
|
+
expect(provisioned->Array.map(r => r.resourceCount))->toEqual([0, 0])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("the components still build normally with a backend registered", async () => {
|
|
102
|
+
let classicOps = await classicLog->ReventlessCore.Component.operations->TestRunner.resolve
|
|
103
|
+
let dcbOps = await dcbLog->DcbMaker.operations->TestRunner.resolve
|
|
104
|
+
// Resolving both operation records proves construction completed with a
|
|
105
|
+
// backend registered — the seam fires mid-construct, so a throwing backend
|
|
106
|
+
// would take the build down here.
|
|
107
|
+
expect(classicOps)->toBeTruthy
|
|
108
|
+
expect(dcbOps)->toBeTruthy
|
|
109
|
+
})
|
|
110
|
+
})
|