@reventlessdev/reventless-local 3.0.0-alpha.182 → 3.0.0-alpha.184
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 +16 -0
- package/package.json +13 -12
- package/rescript.json +1 -0
- package/src/EmitCapabilities.res +16 -31
- package/src/Platform.res +2 -3
- package/src/adapter/Api/LocalEvents_Server.res +12 -1
- package/src/adapter/Api/LocalEvents_Server.res.mjs +3 -1
- package/src/adapter/Auth/LocalAuth.res +5 -12
- package/src/adapter/Auth/UserStore.res +3 -7
- package/src/adapter/Auth/UserStore.res.mjs +1 -1
- package/src/adapter/Backend.res +2 -4
- package/src/adapter/DomainGraphQL_Server.res +2 -4
- package/src/adapter/DomainGraphQL_Server.res.mjs +2 -2
- package/src/adapter/DomainMCP_Server.res +1 -2
- package/src/adapter/LocalBus.res +10 -9
- package/src/adapter/MCP_ServerInstance.res +1 -2
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +34 -6
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +25 -6
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +39 -6
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +26 -6
- package/src/adapter/SqliteDriver.res +1 -5
- package/tests/adapter/BackendParityTest.res +95 -2
- package/tests/adapter/BackendParityTest.res.mjs +96 -0
- package/tests/adapter/EventTapTest.res +3 -4
- package/tests/adapter/LocalAuthUserStoreTest.res +5 -10
- package/tests/adapter/LocalEvents_ServerTest.res +30 -0
- package/tests/adapter/LocalEvents_ServerTest.res.mjs +18 -0
- package/tests/adapter/PgProjectionCatchupTest.res +1 -2
- package/tests/adapter/SqliteDriverTest.res +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
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.184 (2026-07-31)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @reventlessdev/reventless-local
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 3.0.0-alpha.183 (2026-07-30)
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* **local:** a row saved for the first time is Added, not Updated ([6947253](https://github.com/ReventlessDev/reventless-core/commit/6947253a94f9e5fdfeb42fd9268795c948871803))
|
|
19
|
+
* **local:** publish live descriptors on the list-field channel clients subscribe to ([912940d](https://github.com/ReventlessDev/reventless-core/commit/912940dbd5d180b4b3222e5cb954362be2671594))
|
|
20
|
+
|
|
21
|
+
|
|
6
22
|
# 3.0.0-alpha.182 (2026-07-30)
|
|
7
23
|
|
|
8
24
|
**Note:** Version bump only for package @reventlessdev/reventless-local
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-local",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.184",
|
|
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,17 +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.
|
|
38
|
-
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.
|
|
39
|
-
"@reventlessdev/rescript-
|
|
40
|
-
"@reventlessdev/rescript-
|
|
41
|
-
"@reventlessdev/rescript-
|
|
42
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.
|
|
43
|
-
"@reventlessdev/reventless-gwt": "1.0.0-alpha.
|
|
44
|
-
"@reventlessdev/
|
|
45
|
-
"@reventlessdev/reventless-
|
|
46
|
-
"@reventlessdev/reventless-
|
|
47
|
-
"@reventlessdev/reventless-
|
|
37
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
38
|
+
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.26",
|
|
39
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
40
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.0",
|
|
41
|
+
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
|
|
42
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.196",
|
|
43
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.143",
|
|
44
|
+
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
|
|
45
|
+
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.44",
|
|
46
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.60",
|
|
47
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.114",
|
|
48
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.89"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"rescript": "12.3.0",
|
package/rescript.json
CHANGED
package/src/EmitCapabilities.res
CHANGED
|
@@ -20,21 +20,6 @@
|
|
|
20
20
|
// Usage: emit-capabilities <srcDir> [<compositionModule>]
|
|
21
21
|
// (run from the plugin package, after `rescript build`)
|
|
22
22
|
|
|
23
|
-
// ── Node ─────────────────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
@module("node:fs") external existsSync: string => bool = "existsSync"
|
|
26
|
-
@module("node:fs") external writeFileSync: (string, string, @as("utf8") _) => unit = "writeFileSync"
|
|
27
|
-
|
|
28
|
-
@module("node:path") @variadic external join: array<string> => string = "join"
|
|
29
|
-
@module("node:path") @variadic external resolve: array<string> => string = "resolve"
|
|
30
|
-
@module("node:path") @val external sep: string = "sep"
|
|
31
|
-
|
|
32
|
-
@module("node:url") external pathToFileURL: string => {"href": string} = "pathToFileURL"
|
|
33
|
-
|
|
34
|
-
@val @scope("process") external argv: array<string> = "argv"
|
|
35
|
-
@val @scope("process") external env: Dict.t<string> = "env"
|
|
36
|
-
@val external processExit: int => unit = "process.exit"
|
|
37
|
-
|
|
38
23
|
// Emitted by ReScript as a literal `import(...)` expression — the composition
|
|
39
24
|
// root's path is only known at run time, so it cannot be a static binding.
|
|
40
25
|
@val external dynImport: string => promise<'a> = "import"
|
|
@@ -56,11 +41,11 @@ type compositionExports = {"Make": platform => builtPlugin}
|
|
|
56
41
|
let compositionModulePath = (~srcDir: string, ~moduleArg: option<string>): string => {
|
|
57
42
|
let withExtension = name => name->String.endsWith(".mjs") ? name : name ++ ".res.mjs"
|
|
58
43
|
switch moduleArg {
|
|
59
|
-
| None | Some("") => join([srcDir, "Plugin.res.mjs"])
|
|
44
|
+
| None | Some("") => NodePath.join([srcDir, "Plugin.res.mjs"])
|
|
60
45
|
| Some(name) =>
|
|
61
|
-
name->String.includes("/") || name->String.includes(sep)
|
|
62
|
-
? resolve([withExtension(name)])
|
|
63
|
-
: join([srcDir, withExtension(name)])
|
|
46
|
+
name->String.includes("/") || name->String.includes(NodePath.sep)
|
|
47
|
+
? NodePath.resolve([withExtension(name)])
|
|
48
|
+
: NodePath.join([srcDir, withExtension(name)])
|
|
64
49
|
}
|
|
65
50
|
}
|
|
66
51
|
|
|
@@ -68,25 +53,25 @@ let compositionModulePath = (~srcDir: string, ~moduleArg: option<string>): strin
|
|
|
68
53
|
|
|
69
54
|
let fail = (message: string) => {
|
|
70
55
|
Console.error("emit-capabilities: " ++ message)
|
|
71
|
-
|
|
56
|
+
NodeProcess.exit(1)
|
|
72
57
|
}
|
|
73
58
|
|
|
74
59
|
let main = async () => {
|
|
75
60
|
// The local platform defaults to Debug-level logging; a build step should not.
|
|
76
|
-
switch env->Dict.get("LOG_LEVEL") {
|
|
61
|
+
switch NodeProcess.env->Dict.get("LOG_LEVEL") {
|
|
77
62
|
| Some(_) => ()
|
|
78
|
-
| None => env->Dict.set("LOG_LEVEL", "warn")
|
|
63
|
+
| None => NodeProcess.env->Dict.set("LOG_LEVEL", "warn")
|
|
79
64
|
}
|
|
80
65
|
|
|
81
|
-
switch argv->Array.get(2) {
|
|
66
|
+
switch NodeProcess.argv->Array.get(2) {
|
|
82
67
|
| None | Some("") => {
|
|
83
68
|
Console.error("Usage: emit-capabilities <srcDir> [<compositionModule>]")
|
|
84
|
-
|
|
69
|
+
NodeProcess.exit(1)
|
|
85
70
|
}
|
|
86
71
|
| Some(srcDirArg) => {
|
|
87
|
-
let srcDir = resolve([srcDirArg])
|
|
88
|
-
let modulePath = compositionModulePath(~srcDir, ~moduleArg=argv->Array.get(3))
|
|
89
|
-
if !existsSync(modulePath) {
|
|
72
|
+
let srcDir = NodePath.resolve([srcDirArg])
|
|
73
|
+
let modulePath = compositionModulePath(~srcDir, ~moduleArg=NodeProcess.argv->Array.get(3))
|
|
74
|
+
if !NodeFs.existsSync(modulePath) {
|
|
90
75
|
fail(`${modulePath} not found — run \`rescript build\` first.`)
|
|
91
76
|
}
|
|
92
77
|
|
|
@@ -95,11 +80,11 @@ let main = async () => {
|
|
|
95
80
|
let platformModule: localPlatformExports = await dynImport("./Platform.res.mjs")
|
|
96
81
|
let platform = platformModule["Make"]()
|
|
97
82
|
|
|
98
|
-
let composition: compositionExports = await dynImport(pathToFileURL(modulePath)["href"])
|
|
83
|
+
let composition: compositionExports = await dynImport(NodeUrl.pathToFileURL(modulePath)["href"])
|
|
99
84
|
let built = composition["Make"](platform)
|
|
100
85
|
|
|
101
|
-
let manifestPath = join([srcDir, "capabilities.json"])
|
|
102
|
-
writeFileSync(
|
|
86
|
+
let manifestPath = NodePath.join([srcDir, "capabilities.json"])
|
|
87
|
+
NodeFs.writeFileSync(
|
|
103
88
|
manifestPath,
|
|
104
89
|
Reventless.CapabilityManifest.renderForStructure(built["pluginStructure"]),
|
|
105
90
|
)
|
|
@@ -107,7 +92,7 @@ let main = async () => {
|
|
|
107
92
|
|
|
108
93
|
// Applying the platform functor wires in-process infrastructure; exit
|
|
109
94
|
// explicitly so no lingering handle keeps the build step alive.
|
|
110
|
-
|
|
95
|
+
NodeProcess.exit(0)
|
|
111
96
|
}
|
|
112
97
|
}
|
|
113
98
|
}
|
package/src/Platform.res
CHANGED
|
@@ -821,15 +821,14 @@ module MakeWithConfig = (
|
|
|
821
821
|
component->ReventlessCore.Component.operations
|
|
822
822
|
}
|
|
823
823
|
|
|
824
|
-
|
|
825
|
-
let graphqlDebug = processEnv->Dict.get("GRAPHQL_DEBUG")->Option.isSome
|
|
824
|
+
let graphqlDebug = NodeProcess.env->Dict.get("GRAPHQL_DEBUG")->Option.isSome
|
|
826
825
|
|
|
827
826
|
// Per-session server ports for the VS Code local platform runner (features
|
|
828
827
|
// plan Phase 9). The runner spawns one platform child per app folder; default
|
|
829
828
|
// ports (4000/4001/3001/3002) collide when several run, so each is overridable
|
|
830
829
|
// via env. Defaults preserve today's behaviour for all non-runner callers.
|
|
831
830
|
let resolvePort = (key, fallback) =>
|
|
832
|
-
|
|
831
|
+
NodeProcess.env->Dict.get(key)->Option.flatMap(v => Int.fromString(v))->Option.getOr(fallback)
|
|
833
832
|
let domainPort = resolvePort("REVENTLESS_DOMAIN_PORT", 4000)
|
|
834
833
|
let domainMcpPort = resolvePort("REVENTLESS_DOMAIN_MCP_PORT", 3001)
|
|
835
834
|
let platformPort = resolvePort("REVENTLESS_PLATFORM_PORT", 4001)
|
|
@@ -111,7 +111,18 @@ let broadcastStateChange = (~name: string, ~descriptor: JSON.t): unit => {
|
|
|
111
111
|
->Option.flatMap(JSON.Decode.string)
|
|
112
112
|
->Option.getOr("")
|
|
113
113
|
if entityKey != "" {
|
|
114
|
-
|
|
114
|
+
// Channel root MUST equal what the host-shell subscribes to. AutoLive uses
|
|
115
|
+
// `queryField = listFieldName` (plural, plugin-qualified, e.g.
|
|
116
|
+
// "Catalog_Products"), not the read-model store name ("Products"). Translate
|
|
117
|
+
// through the same registry the AWS StateTopic wiring uses (aws/Platform.res)
|
|
118
|
+
// — publishing on the raw store name lands descriptors on a channel no client
|
|
119
|
+
// listens to, so the socket stays Open but no view ever refreshes.
|
|
120
|
+
let topicName =
|
|
121
|
+
ReventlessCore.Plugin_Helpers.queryFieldNamesRegistry
|
|
122
|
+
->Dict.get(name)
|
|
123
|
+
->Option.map(qn => qn.listFieldName)
|
|
124
|
+
->Option.getOr(name)
|
|
125
|
+
let channel = `/default/${pathSegment(topicName)}/${pathSegment(entityKey)}`
|
|
115
126
|
broadcast(~channel, ~event=descriptor->JSON.stringify)
|
|
116
127
|
}
|
|
117
128
|
}
|
|
@@ -6,6 +6,7 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
|
6
6
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
7
|
import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
|
|
8
8
|
import * as LocalAuth$ReventlessLocal from "../Auth/LocalAuth.res.mjs";
|
|
9
|
+
import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
|
|
9
10
|
|
|
10
11
|
let log = Logger$ReventlessCore.fromEnv();
|
|
11
12
|
|
|
@@ -99,7 +100,8 @@ function broadcastStateChange(name, descriptor) {
|
|
|
99
100
|
if (entityKey === "") {
|
|
100
101
|
return;
|
|
101
102
|
}
|
|
102
|
-
let
|
|
103
|
+
let topicName = Stdlib_Option.getOr(Stdlib_Option.map(Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry[name], qn => qn.listFieldName), name);
|
|
104
|
+
let channel = `/default/` + pathSegment(topicName) + `/` + pathSegment(entityKey);
|
|
103
105
|
broadcast(channel, JSON.stringify(descriptor));
|
|
104
106
|
}
|
|
105
107
|
|
|
@@ -95,24 +95,15 @@ module Login = {
|
|
|
95
95
|
// after a backend reload. When unset, falls back to a random per-process
|
|
96
96
|
// secret — secure for ephemeral test runs but invalidates every previously
|
|
97
97
|
// issued token on every restart.
|
|
98
|
-
type buf
|
|
99
|
-
@module("node:crypto") external _randomBytes: int => buf = "randomBytes"
|
|
100
|
-
@send external _bufToString: (buf, string) => string = "toString"
|
|
101
|
-
type hmac
|
|
102
|
-
@module("node:crypto") external _createHmac: (string, string) => hmac = "createHmac"
|
|
103
|
-
@send external _update: (hmac, string) => hmac = "update"
|
|
104
|
-
@send external _digest: (hmac, string) => string = "digest"
|
|
105
|
-
|
|
106
|
-
@val external _processEnv: Dict.t<string> = "process.env"
|
|
107
98
|
|
|
108
99
|
let _secret: ref<option<string>> = ref(None)
|
|
109
100
|
let _getSecret = (): string =>
|
|
110
101
|
switch _secret.contents {
|
|
111
102
|
| Some(s) => s
|
|
112
103
|
| None =>
|
|
113
|
-
let s = switch
|
|
104
|
+
let s = switch NodeProcess.env->Dict.get("REVENTLESS_INMEMORY_TOKEN_SECRET") {
|
|
114
105
|
| Some(envSecret) if String.length(envSecret) >= 16 => envSecret
|
|
115
|
-
| _ =>
|
|
106
|
+
| _ => NodeCrypto.randomBytes(32)->NodeCrypto.bufferToString("hex")
|
|
116
107
|
}
|
|
117
108
|
_secret := Some(s)
|
|
118
109
|
s
|
|
@@ -121,7 +112,9 @@ module Login = {
|
|
|
121
112
|
let setTokenSecret = (s: string): unit => _secret := Some(s)
|
|
122
113
|
|
|
123
114
|
let _sign = (payload: string): string =>
|
|
124
|
-
|
|
115
|
+
NodeCrypto.createHmac("sha256", _getSecret())
|
|
116
|
+
->NodeCrypto.hmacUpdate(payload)
|
|
117
|
+
->NodeCrypto.hmacDigest("base64url")
|
|
125
118
|
|
|
126
119
|
// base64url over arbitrary JSON strings: btoa-encode + URL-safe alphabet,
|
|
127
120
|
// strip `=` padding. Avoids pulling in Buffer for a single-line conversion.
|
|
@@ -26,10 +26,6 @@ type entry = {
|
|
|
26
26
|
// ── YAML parsing (eemeli/yaml) ──────────────────────────────────────────────
|
|
27
27
|
|
|
28
28
|
@module("yaml") external _yamlParse: string => unknown = "parse"
|
|
29
|
-
@module("node:fs") external _readFileSync: (string, string) => string = "readFileSync"
|
|
30
|
-
@module("node:fs") external _existsSync: string => bool = "existsSync"
|
|
31
|
-
@module("node:path") external _join: (string, string) => string = "join"
|
|
32
|
-
@val external _processCwd: unit => string = "process.cwd"
|
|
33
29
|
|
|
34
30
|
let _entriesSchema = S.array(entrySchema)
|
|
35
31
|
|
|
@@ -46,7 +42,7 @@ let parseString = (yamlText: string): result<array<entry>, string> =>
|
|
|
46
42
|
/** Reads + parses a YAML file. */
|
|
47
43
|
let parseFile = (path: string): result<array<entry>, string> =>
|
|
48
44
|
try {
|
|
49
|
-
let text =
|
|
45
|
+
let text = NodeFs.readFileSync(path)
|
|
50
46
|
parseString(text)
|
|
51
47
|
} catch {
|
|
52
48
|
| JsExn(err) => Error(JsExn.message(err)->Option.getOr(`Cannot read ${path}`))
|
|
@@ -66,7 +62,7 @@ let _registerEntries = (entries: array<entry>): unit =>
|
|
|
66
62
|
LocalAuth.Login.setCredentials(~username, ~password, ~identity=id)
|
|
67
63
|
})
|
|
68
64
|
|
|
69
|
-
let _defaultPath = (): string =>
|
|
65
|
+
let _defaultPath = (): string => NodePath.join([NodeProcess.cwd(), ".reventless/users.yaml"])
|
|
70
66
|
|
|
71
67
|
/**
|
|
72
68
|
* Hydrates the Login store. Returns the resolution path actually used
|
|
@@ -106,7 +102,7 @@ let load = (
|
|
|
106
102
|
})
|
|
107
103
|
| None =>
|
|
108
104
|
let defaultPath = _defaultPath()
|
|
109
|
-
if
|
|
105
|
+
if NodeFs.existsSync(defaultPath) {
|
|
110
106
|
parseFile(defaultPath)->Result.map(entries => {
|
|
111
107
|
_registerEntries(entries)
|
|
112
108
|
DefaultFile(defaultPath)
|
|
@@ -106,7 +106,7 @@ function load(users, usersFile, param) {
|
|
|
106
106
|
};
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
|
-
let defaultPath =
|
|
109
|
+
let defaultPath = _defaultPath();
|
|
110
110
|
if (Nodefs.existsSync(defaultPath)) {
|
|
111
111
|
return Stdlib_Result.map(parseFile(defaultPath), entries => {
|
|
112
112
|
_registerEntries(entries);
|
package/src/adapter/Backend.res
CHANGED
|
@@ -34,13 +34,11 @@ let postgres = async (~connection, ~resetOnStart=false) => {
|
|
|
34
34
|
Postgres({pool, initialCount, connection})
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
@val external processEnv: dict<string> = "process.env"
|
|
38
37
|
|
|
39
38
|
// Deletes a file if it exists. No-op if the path is missing.
|
|
40
|
-
@module("node:fs") external _unlinkSync: string => unit = "unlinkSync"
|
|
41
39
|
|
|
42
40
|
let removeFileIfExists = (path: string) =>
|
|
43
|
-
try
|
|
41
|
+
try NodeFs.unlinkSync(path) catch {
|
|
44
42
|
| _ => ()
|
|
45
43
|
}
|
|
46
44
|
|
|
@@ -50,7 +48,7 @@ let removeFileIfExists = (path: string) =>
|
|
|
50
48
|
// memory — explicit Memory selector
|
|
51
49
|
// Anything else (including unset) yields Memory.
|
|
52
50
|
let fromEnv = () =>
|
|
53
|
-
switch
|
|
51
|
+
switch NodeProcess.env->Dict.get("REVENTLESS_LOCAL_BACKEND") {
|
|
54
52
|
| None => Memory
|
|
55
53
|
| Some(raw) =>
|
|
56
54
|
let trimmed = raw->String.trim
|
|
@@ -37,7 +37,6 @@ type nodeResponse
|
|
|
37
37
|
// mangled by utf8 decoding (setEncoding is deliberately NOT called).
|
|
38
38
|
@send external _onDataBuf: (nodeRequest, @as("data") _, LocalObjectStore.buffer => unit) => unit = "on"
|
|
39
39
|
|
|
40
|
-
@module("crypto") external randomUUID: unit => string = "randomUUID"
|
|
41
40
|
|
|
42
41
|
let readBody = (req: nodeRequest, onBody: string => unit): unit => {
|
|
43
42
|
let buf = ref("")
|
|
@@ -155,7 +154,7 @@ let handleUploadPresign = (req: nodeRequest, res: nodeResponse): unit =>
|
|
|
155
154
|
}
|
|
156
155
|
let fileName =
|
|
157
156
|
parsed->Dict.get("fileName")->Option.flatMap(JSON.Decode.string)->Option.getOr("upload")
|
|
158
|
-
let ref = `/${LocalObjectStore.defaultUploadPrefix}/${randomUUID()}/${fileName}`
|
|
157
|
+
let ref = `/${LocalObjectStore.defaultUploadPrefix}/${NodeCrypto.randomUUID()}/${fileName}`
|
|
159
158
|
_writeJson(
|
|
160
159
|
res,
|
|
161
160
|
~status=200,
|
|
@@ -496,8 +495,7 @@ let buildNodeResolver = (): option<(string, resolverFn)> =>
|
|
|
496
495
|
|
|
497
496
|
// -- Server lifecycle ------------------------------------------------------
|
|
498
497
|
|
|
499
|
-
|
|
500
|
-
let debug = processEnv->Dict.get("GRAPHQL_DEBUG")->Option.isSome
|
|
498
|
+
let debug = NodeProcess.env->Dict.get("GRAPHQL_DEBUG")->Option.isSome
|
|
501
499
|
|
|
502
500
|
let activeServer: ref<option<YG.httpServer>> = ref(None)
|
|
503
501
|
let activeSchema: ref<option<YG.schema>> = ref(None)
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import * as S from "sury/src/S.res.mjs";
|
|
4
4
|
import * as Http from "http";
|
|
5
|
-
import * as Crypto from "crypto";
|
|
6
5
|
import * as Graphql from "graphql";
|
|
7
6
|
import * as GraphqlYoga from "@reventlessdev/rescript-graphql-yoga/src/GraphqlYoga.res.mjs";
|
|
8
7
|
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
9
8
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
9
|
+
import * as Nodecrypto from "node:crypto";
|
|
10
10
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
11
11
|
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
12
12
|
import * as GraphqlYoga$1 from "graphql-yoga";
|
|
@@ -117,7 +117,7 @@ function handleUploadPresign(req, res) {
|
|
|
117
117
|
parsed = {};
|
|
118
118
|
}
|
|
119
119
|
let fileName = Stdlib_Option.getOr(Stdlib_Option.flatMap(parsed["fileName"], Stdlib_JSON.Decode.string), "upload");
|
|
120
|
-
let ref = `/` + LocalObjectStore$ReventlessLocal.defaultUploadPrefix + `/` +
|
|
120
|
+
let ref = `/` + LocalObjectStore$ReventlessLocal.defaultUploadPrefix + `/` + Nodecrypto.randomUUID() + `/` + fileName;
|
|
121
121
|
_writeJson(res, 200, Object.fromEntries([
|
|
122
122
|
[
|
|
123
123
|
"uploadUrl",
|
|
@@ -132,8 +132,7 @@ let registerEventHistoryResourcesFromEntries = (
|
|
|
132
132
|
|
|
133
133
|
let activeServer: ref<option<McpSdk.httpServer>> = ref(None)
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
let debug = processEnv->Dict.get("MCP_DEBUG")->Option.isSome
|
|
135
|
+
let debug = NodeProcess.env->Dict.get("MCP_DEBUG")->Option.isSome
|
|
137
136
|
|
|
138
137
|
// Extract identity from an HTTP IncomingMessage via the X-Identity header.
|
|
139
138
|
// Falls back to Identity.anonymous when the header is absent or malformed.
|
package/src/adapter/LocalBus.res
CHANGED
|
@@ -68,9 +68,9 @@ let pickSortKeyValue = (state: JSON.t): option<string> =>
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/** Build a state-change descriptor.
|
|
71
|
-
- `changeKind`: one of "Added" | "Updated" | "Removed".
|
|
72
|
-
|
|
73
|
-
|
|
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
74
|
- `id`: entity key. Single-key projections: the partition-key value.
|
|
75
75
|
Composite projections: `partition ++ "-" ++ subKey`.
|
|
76
76
|
- `state`: present for save() (for `sortKeyValue` extraction);
|
|
@@ -98,10 +98,9 @@ let makeStateChangeDescriptor = (
|
|
|
98
98
|
// so the runner's line parser can pick it out of the platform's ANSI log noise on
|
|
99
99
|
// stdout. The emit is a plain Console.log, so it never perturbs publishEvent's
|
|
100
100
|
// subscriber-countdown delivery semantics.
|
|
101
|
-
@val external tapProcessEnv: dict<string> = "process.env"
|
|
102
101
|
// Read per publish (not once at import) so the runner can toggle it live and so
|
|
103
102
|
// hermetic tests can flip it between cases. The cost is one dict lookup per event.
|
|
104
|
-
let eventTapEnabled = () =>
|
|
103
|
+
let eventTapEnabled = () => NodeProcess.env->Dict.get("REVENTLESS_EVENT_TAP")->Option.isSome
|
|
105
104
|
let eventTapSeq = ref(0)
|
|
106
105
|
// Seed the in-memory tap counter from a persistent store's existing event count
|
|
107
106
|
// so the timeline's #N continues across process restarts / app switches instead
|
|
@@ -210,10 +209,12 @@ module type T = {
|
|
|
210
209
|
// ~name is the QueryDb/ReadModel Spec.name; ~descriptor is built via
|
|
211
210
|
// `makeStateChangeDescriptor` below.
|
|
212
211
|
//
|
|
213
|
-
//
|
|
214
|
-
// (
|
|
215
|
-
//
|
|
216
|
-
//
|
|
212
|
+
// `changeKind` matches AWS, which reads it off the DynamoDB stream eventName:
|
|
213
|
+
// save() checks whether a visible row already held the key and emits "Added"
|
|
214
|
+
// or "Updated" accordingly, `delete()` emits "Removed". Without the "Added"
|
|
215
|
+
// 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).
|
|
217
218
|
let publishStateChange: (~name: string, ~descriptor: JSON.t) => unit
|
|
218
219
|
let subscribeToStateChanges: (string, JSON.t => unit) => unit
|
|
219
220
|
// All-changes variant: receives every publishStateChange with its read-model
|
|
@@ -51,8 +51,7 @@ type t = {
|
|
|
51
51
|
printDiagnostics: unit => unit,
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
let debug = processEnv->Dict.get("MCP_DEBUG")->Option.isSome
|
|
54
|
+
let debug = NodeProcess.env->Dict.get("MCP_DEBUG")->Option.isSome
|
|
56
55
|
|
|
57
56
|
let make = (~label: string="MCP"): t => {
|
|
58
57
|
let tools: ref<dict<registeredTool>> = ref(Dict.make())
|
|
@@ -146,6 +146,24 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// First-insert detection, mirroring the SQLite arm's `existsStmt`. Expiry is
|
|
150
|
+
// purged lazily, so a key can still be in the store while no reader can see
|
|
151
|
+
// it — that counts as absent, or an aged-out row would come back as an
|
|
152
|
+
// Updated nobody has a row for.
|
|
153
|
+
let entryIsLive = (id: string, subKey: string): bool =>
|
|
154
|
+
switch store.contents->Dict.get(id) {
|
|
155
|
+
| None => false
|
|
156
|
+
| Some(subMap) =>
|
|
157
|
+
subMap->Dict.get(subKey)->Option.isSome &&
|
|
158
|
+
expiries.contents
|
|
159
|
+
->Dict.get(id)
|
|
160
|
+
->Option.flatMap(m => m->Dict.get(subKey))
|
|
161
|
+
->Option.mapOr(true, exp => exp > nowSecs())
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let saveKind = (id: string, subKey: string): string =>
|
|
165
|
+
entryIsLive(id, subKey) ? "Updated" : "Added"
|
|
166
|
+
|
|
149
167
|
let getOrCreateSubMap = (partitionKey: string): dict<JSON.t> =>
|
|
150
168
|
switch store.contents->Dict.get(partitionKey) {
|
|
151
169
|
| Some(m) => m
|
|
@@ -173,10 +191,10 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
173
191
|
| None => id
|
|
174
192
|
}
|
|
175
193
|
|
|
176
|
-
let
|
|
194
|
+
let publishSaved = (~changeKind: string, id: string, state: JSON.t) => {
|
|
177
195
|
let subKey = getSubKey(state, subIdField)
|
|
178
196
|
let descriptor = LocalBus.makeStateChangeDescriptor(
|
|
179
|
-
~changeKind
|
|
197
|
+
~changeKind,
|
|
180
198
|
~id=entityKeyFor(id, subKey),
|
|
181
199
|
~state=Some(state),
|
|
182
200
|
)
|
|
@@ -194,24 +212,31 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
194
212
|
|
|
195
213
|
let save: QueryDb.save<string, JSON.t> = async (id, state, _saveMode, ttl) => {
|
|
196
214
|
let subKey = getSubKey(state, subIdField)
|
|
215
|
+
let changeKind = saveKind(id, subKey)
|
|
197
216
|
let subMap = getOrCreateSubMap(id)
|
|
198
217
|
subMap->Dict.set(subKey, state)
|
|
199
218
|
recordExpiry(id, subKey, ttl)
|
|
200
219
|
syncAll()
|
|
201
|
-
|
|
220
|
+
publishSaved(~changeKind, id, state)
|
|
202
221
|
Ok()
|
|
203
222
|
}
|
|
204
223
|
|
|
205
224
|
let saveBatch: QueryDb.saveBatch<string, JSON.t> = async batch => {
|
|
206
|
-
|
|
225
|
+
// Each kind is read immediately before its own write, so a batch that
|
|
226
|
+
// touches one key twice reports Added then Updated.
|
|
227
|
+
let kinds = batch->Array.map(((id, state, ttl)) => {
|
|
207
228
|
let subKey = getSubKey(state, subIdField)
|
|
229
|
+
let changeKind = saveKind(id, subKey)
|
|
208
230
|
let subMap = getOrCreateSubMap(id)
|
|
209
231
|
subMap->Dict.set(subKey, state)
|
|
210
232
|
recordExpiry(id, subKey, ttl)
|
|
233
|
+
changeKind
|
|
211
234
|
})
|
|
212
235
|
syncAll()
|
|
213
236
|
// Notify subscribers for each saved item
|
|
214
|
-
batch->Array.
|
|
237
|
+
batch->Array.forEachWithIndex(((id, state, _), i) =>
|
|
238
|
+
publishSaved(~changeKind=kinds->Array.get(i)->Option.getOr("Updated"), id, state)
|
|
239
|
+
)
|
|
215
240
|
Ok()
|
|
216
241
|
}
|
|
217
242
|
|
|
@@ -221,6 +246,9 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
221
246
|
// previous `Ok(inc)` echoed the increment and never touched the store, so the
|
|
222
247
|
// running total was wrong and `loadStream` never reflected the counter.
|
|
223
248
|
let count: QueryDb.count<string> = async (id, fieldName, inc) => {
|
|
249
|
+
// Read before `getOrCreateSubMap` materialises the partition — a counter's
|
|
250
|
+
// very first increment creates its row, and that is an Added like any other.
|
|
251
|
+
let changeKind = saveKind(id, "")
|
|
224
252
|
let subMap = getOrCreateSubMap(id)
|
|
225
253
|
let existing = subMap->Dict.get("")->Option.flatMap(JSON.Decode.object)
|
|
226
254
|
let current =
|
|
@@ -239,7 +267,7 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
239
267
|
let newItem = JSON.Encode.object(obj)
|
|
240
268
|
subMap->Dict.set("", newItem)
|
|
241
269
|
syncAll()
|
|
242
|
-
|
|
270
|
+
publishSaved(~changeKind, id, newItem)
|
|
243
271
|
Ok(next)
|
|
244
272
|
}
|
|
245
273
|
|
|
@@ -123,6 +123,21 @@ function Make(Bus) {
|
|
|
123
123
|
return;
|
|
124
124
|
}
|
|
125
125
|
};
|
|
126
|
+
let entryIsLive = (id, subKey) => {
|
|
127
|
+
let subMap = store.contents[id];
|
|
128
|
+
if (subMap !== undefined && Stdlib_Option.isSome(subMap[subKey])) {
|
|
129
|
+
return Stdlib_Option.mapOr(Stdlib_Option.flatMap(expiries.contents[id], m => m[subKey]), true, exp => exp > Date.now() / 1000.0);
|
|
130
|
+
} else {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
let saveKind = (id, subKey) => {
|
|
135
|
+
if (entryIsLive(id, subKey)) {
|
|
136
|
+
return "Updated";
|
|
137
|
+
} else {
|
|
138
|
+
return "Added";
|
|
139
|
+
}
|
|
140
|
+
};
|
|
126
141
|
let getOrCreateSubMap = partitionKey => {
|
|
127
142
|
let m = store.contents[partitionKey];
|
|
128
143
|
if (m !== undefined) {
|
|
@@ -150,9 +165,9 @@ function Make(Bus) {
|
|
|
150
165
|
return id;
|
|
151
166
|
}
|
|
152
167
|
};
|
|
153
|
-
let
|
|
168
|
+
let publishSaved = (changeKind, id, state) => {
|
|
154
169
|
let subKey = getSubKey(state, subIdField);
|
|
155
|
-
let descriptor = LocalBus$ReventlessLocal.makeStateChangeDescriptor(
|
|
170
|
+
let descriptor = LocalBus$ReventlessLocal.makeStateChangeDescriptor(changeKind, entityKeyFor(id, subKey), state);
|
|
156
171
|
Bus.publishStateChange(name, descriptor);
|
|
157
172
|
};
|
|
158
173
|
let publishRemoved = (id, subKey) => {
|
|
@@ -161,33 +176,37 @@ function Make(Bus) {
|
|
|
161
176
|
};
|
|
162
177
|
let save = async (id, state, _saveMode, ttl) => {
|
|
163
178
|
let subKey = getSubKey(state, subIdField);
|
|
179
|
+
let changeKind = saveKind(id, subKey);
|
|
164
180
|
let subMap = getOrCreateSubMap(id);
|
|
165
181
|
subMap[subKey] = state;
|
|
166
182
|
recordExpiry(id, subKey, ttl);
|
|
167
183
|
dirty.contents = true;
|
|
168
|
-
|
|
184
|
+
publishSaved(changeKind, id, state);
|
|
169
185
|
return {
|
|
170
186
|
TAG: "Ok",
|
|
171
187
|
_0: undefined
|
|
172
188
|
};
|
|
173
189
|
};
|
|
174
190
|
let saveBatch = async batch => {
|
|
175
|
-
batch.
|
|
191
|
+
let kinds = batch.map(param => {
|
|
176
192
|
let state = param[1];
|
|
177
193
|
let id = param[0];
|
|
178
194
|
let subKey = getSubKey(state, subIdField);
|
|
195
|
+
let changeKind = saveKind(id, subKey);
|
|
179
196
|
let subMap = getOrCreateSubMap(id);
|
|
180
197
|
subMap[subKey] = state;
|
|
181
198
|
recordExpiry(id, subKey, param[2]);
|
|
199
|
+
return changeKind;
|
|
182
200
|
});
|
|
183
201
|
dirty.contents = true;
|
|
184
|
-
batch.forEach(param =>
|
|
202
|
+
batch.forEach((param, i) => publishSaved(Stdlib_Option.getOr(kinds[i], "Updated"), param[0], param[1]));
|
|
185
203
|
return {
|
|
186
204
|
TAG: "Ok",
|
|
187
205
|
_0: undefined
|
|
188
206
|
};
|
|
189
207
|
};
|
|
190
208
|
let count = async (id, fieldName, inc) => {
|
|
209
|
+
let changeKind = saveKind(id, "");
|
|
191
210
|
let subMap = getOrCreateSubMap(id);
|
|
192
211
|
let existing = Stdlib_Option.flatMap(subMap[""], Stdlib_JSON.Decode.object);
|
|
193
212
|
let current = Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(existing, o => o[fieldName]), Stdlib_JSON.Decode.float), 0, prim => prim | 0);
|
|
@@ -202,7 +221,7 @@ function Make(Bus) {
|
|
|
202
221
|
obj[fieldName] = next;
|
|
203
222
|
subMap[""] = obj;
|
|
204
223
|
dirty.contents = true;
|
|
205
|
-
|
|
224
|
+
publishSaved(changeKind, id, obj);
|
|
206
225
|
return {
|
|
207
226
|
TAG: "Ok",
|
|
208
227
|
_0: next
|
|
@@ -181,6 +181,15 @@ let makeStorage = (
|
|
|
181
181
|
let scanAllStmt = db->SqliteDriver.prepare(
|
|
182
182
|
`SELECT partition_key, sub_key, item FROM ${table} WHERE ${notExpiredClause} ORDER BY partition_key, sub_key`,
|
|
183
183
|
)
|
|
184
|
+
// First-insert detection. DynamoDB streams hand the AWS StateTopic Lambda an
|
|
185
|
+
// eventName that separates INSERT from MODIFY; an upsert reports neither, so
|
|
186
|
+
// ask the table before writing. Carries `notExpiredClause` because that is what
|
|
187
|
+
// a reader sees: a row aged past its TTL is invisible to them, so overwriting
|
|
188
|
+
// it is an insert — the same descriptor pair (Removed, then Added) DynamoDB
|
|
189
|
+
// emits when a TTL delete is followed by a fresh Put.
|
|
190
|
+
let existsStmt = db->SqliteDriver.prepare(
|
|
191
|
+
`SELECT 1 FROM ${table} WHERE partition_key = ? AND sub_key = ? AND ${notExpiredClause} LIMIT 1`,
|
|
192
|
+
)
|
|
184
193
|
|
|
185
194
|
let rowsFor = (id: string): array<JSON.t> =>
|
|
186
195
|
selectByPartitionStmt
|
|
@@ -246,16 +255,28 @@ let makeStorage = (
|
|
|
246
255
|
| None => id
|
|
247
256
|
}
|
|
248
257
|
|
|
249
|
-
let
|
|
258
|
+
let publishSaved = (~changeKind: string, id: string, state: JSON.t) => {
|
|
250
259
|
let subKey = computeSubKey(state, subIdField)
|
|
251
260
|
let descriptor = LocalBus.makeStateChangeDescriptor(
|
|
252
|
-
~changeKind
|
|
261
|
+
~changeKind,
|
|
253
262
|
~id=entityKeyFor(id, subKey),
|
|
254
263
|
~state=Some(state),
|
|
255
264
|
)
|
|
256
265
|
bus.publishStateChange(~name, ~descriptor)
|
|
257
266
|
}
|
|
258
267
|
|
|
268
|
+
// Must be called BEFORE the write — afterwards every row exists.
|
|
269
|
+
let saveKind = (id: string, state: JSON.t): string => {
|
|
270
|
+
let present =
|
|
271
|
+
existsStmt
|
|
272
|
+
->SqliteDriver.get([
|
|
273
|
+
JSON.Encode.string(id),
|
|
274
|
+
JSON.Encode.string(computeSubKey(state, subIdField)),
|
|
275
|
+
])
|
|
276
|
+
->Option.isSome
|
|
277
|
+
present ? "Updated" : "Added"
|
|
278
|
+
}
|
|
279
|
+
|
|
259
280
|
let publishRemoved = (id: string, subKey: string) => {
|
|
260
281
|
let descriptor = LocalBus.makeStateChangeDescriptor(
|
|
261
282
|
~changeKind="Removed",
|
|
@@ -277,16 +298,25 @@ let makeStorage = (
|
|
|
277
298
|
)
|
|
278
299
|
|
|
279
300
|
let save: QueryDb.save<string, JSON.t> = async (id, state, _saveMode, ttl) => {
|
|
301
|
+
let changeKind = saveKind(id, state)
|
|
280
302
|
saveOne(id, state, ttl)
|
|
281
|
-
|
|
303
|
+
publishSaved(~changeKind, id, state)
|
|
282
304
|
Ok()
|
|
283
305
|
}
|
|
284
306
|
|
|
285
307
|
let saveBatch: QueryDb.saveBatch<string, JSON.t> = async batch => {
|
|
308
|
+
// Kinds are decided inside the transaction, each immediately before its own
|
|
309
|
+
// write, so a batch that touches one key twice reports Added then Updated.
|
|
310
|
+
let kinds = []
|
|
286
311
|
db->SqliteDriver.transaction(() =>
|
|
287
|
-
batch->Array.forEach(((id, state, ttl)) =>
|
|
312
|
+
batch->Array.forEach(((id, state, ttl)) => {
|
|
313
|
+
kinds->Array.push(saveKind(id, state))
|
|
314
|
+
saveOne(id, state, ttl)
|
|
315
|
+
})
|
|
316
|
+
)
|
|
317
|
+
batch->Array.forEachWithIndex(((id, state, _), i) =>
|
|
318
|
+
publishSaved(~changeKind=kinds->Array.get(i)->Option.getOr("Updated"), id, state)
|
|
288
319
|
)
|
|
289
|
-
batch->Array.forEach(((id, state, _)) => publishUpdated(id, state))
|
|
290
320
|
Ok()
|
|
291
321
|
}
|
|
292
322
|
|
|
@@ -310,8 +340,11 @@ let makeStorage = (
|
|
|
310
340
|
obj->Dict.set("id", JSON.Encode.string(id))
|
|
311
341
|
obj->Dict.set(fieldName, JSON.Encode.int(next))
|
|
312
342
|
let newItem = JSON.Encode.object(obj)
|
|
343
|
+
// `existing` already answered the first-insert question — a counter's very
|
|
344
|
+
// first increment creates its row, and that is an Added like any other.
|
|
345
|
+
let changeKind = existing->Option.isSome ? "Updated" : "Added"
|
|
313
346
|
saveOne(id, newItem, None)
|
|
314
|
-
|
|
347
|
+
publishSaved(~changeKind, id, newItem)
|
|
315
348
|
Ok(next)
|
|
316
349
|
}
|
|
317
350
|
|
|
@@ -147,6 +147,7 @@ function makeStorage(db, bus, name, indexes, subIdField) {
|
|
|
147
147
|
let deleteByPartitionStmt = SqliteDriver$ReventlessLocal.prepare(db, `DELETE FROM ` + table + ` WHERE partition_key = ?`);
|
|
148
148
|
let deleteBySubKeyStmt = SqliteDriver$ReventlessLocal.prepare(db, `DELETE FROM ` + table + ` WHERE partition_key = ? AND sub_key = ?`);
|
|
149
149
|
let scanAllStmt = SqliteDriver$ReventlessLocal.prepare(db, `SELECT partition_key, sub_key, item FROM ` + table + ` WHERE ` + notExpiredClause + ` ORDER BY partition_key, sub_key`);
|
|
150
|
+
let existsStmt = SqliteDriver$ReventlessLocal.prepare(db, `SELECT 1 FROM ` + table + ` WHERE partition_key = ? AND sub_key = ? AND ` + notExpiredClause + ` LIMIT 1`);
|
|
150
151
|
let rowsFor = id => SqliteDriver$ReventlessLocal.all(selectByPartitionStmt, [id]).map(decodeItem);
|
|
151
152
|
let rowToItem = row => {
|
|
152
153
|
let item = decodeItem(row);
|
|
@@ -191,11 +192,22 @@ function makeStorage(db, bus, name, indexes, subIdField) {
|
|
|
191
192
|
return id;
|
|
192
193
|
}
|
|
193
194
|
};
|
|
194
|
-
let
|
|
195
|
+
let publishSaved = (changeKind, id, state) => {
|
|
195
196
|
let subKey = computeSubKey(state, subIdField);
|
|
196
|
-
let descriptor = LocalBus$ReventlessLocal.makeStateChangeDescriptor(
|
|
197
|
+
let descriptor = LocalBus$ReventlessLocal.makeStateChangeDescriptor(changeKind, entityKeyFor(id, subKey), state);
|
|
197
198
|
bus.publishStateChange(name, descriptor);
|
|
198
199
|
};
|
|
200
|
+
let saveKind = (id, state) => {
|
|
201
|
+
let present = Stdlib_Option.isSome(SqliteDriver$ReventlessLocal.get(existsStmt, [
|
|
202
|
+
id,
|
|
203
|
+
computeSubKey(state, subIdField)
|
|
204
|
+
]));
|
|
205
|
+
if (present) {
|
|
206
|
+
return "Updated";
|
|
207
|
+
} else {
|
|
208
|
+
return "Added";
|
|
209
|
+
}
|
|
210
|
+
};
|
|
199
211
|
let publishRemoved = (id, subKey) => {
|
|
200
212
|
let descriptor = LocalBus$ReventlessLocal.makeStateChangeDescriptor("Removed", entityKeyFor(id, subKey), undefined);
|
|
201
213
|
bus.publishStateChange(name, descriptor);
|
|
@@ -209,18 +221,25 @@ function makeStorage(db, bus, name, indexes, subIdField) {
|
|
|
209
221
|
}
|
|
210
222
|
});
|
|
211
223
|
let save = async (id, state, _saveMode, ttl) => {
|
|
224
|
+
let changeKind = saveKind(id, state);
|
|
212
225
|
saveOne(id, state, ttl);
|
|
213
|
-
|
|
226
|
+
publishSaved(changeKind, id, state);
|
|
214
227
|
return {
|
|
215
228
|
TAG: "Ok",
|
|
216
229
|
_0: undefined
|
|
217
230
|
};
|
|
218
231
|
};
|
|
219
232
|
let saveBatch = async batch => {
|
|
233
|
+
let kinds = [];
|
|
220
234
|
SqliteDriver$ReventlessLocal.transaction(db, () => {
|
|
221
|
-
batch.forEach(param =>
|
|
235
|
+
batch.forEach(param => {
|
|
236
|
+
let state = param[1];
|
|
237
|
+
let id = param[0];
|
|
238
|
+
kinds.push(saveKind(id, state));
|
|
239
|
+
saveOne(id, state, param[2]);
|
|
240
|
+
});
|
|
222
241
|
});
|
|
223
|
-
batch.forEach(param =>
|
|
242
|
+
batch.forEach((param, i) => publishSaved(Stdlib_Option.getOr(kinds[i], "Updated"), param[0], param[1]));
|
|
224
243
|
return {
|
|
225
244
|
TAG: "Ok",
|
|
226
245
|
_0: undefined
|
|
@@ -238,8 +257,9 @@ function makeStorage(db, bus, name, indexes, subIdField) {
|
|
|
238
257
|
}
|
|
239
258
|
obj["id"] = id;
|
|
240
259
|
obj[fieldName] = next;
|
|
260
|
+
let changeKind = Stdlib_Option.isSome(existing) ? "Updated" : "Added";
|
|
241
261
|
saveOne(id, obj, undefined);
|
|
242
|
-
|
|
262
|
+
publishSaved(changeKind, id, obj);
|
|
243
263
|
return {
|
|
244
264
|
TAG: "Ok",
|
|
245
265
|
_0: next
|
|
@@ -7,10 +7,6 @@ type statement
|
|
|
7
7
|
@module("node:sqlite") @new
|
|
8
8
|
external openDatabaseSync: string => t = "DatabaseSync"
|
|
9
9
|
|
|
10
|
-
@module("node:path") external dirname: string => string = "dirname"
|
|
11
|
-
type mkdirOpts = {recursive: bool}
|
|
12
|
-
@module("node:fs") external mkdirSync: (string, mkdirOpts) => unit = "mkdirSync"
|
|
13
|
-
|
|
14
10
|
@send external _close: t => unit = "close"
|
|
15
11
|
@send external _exec: (t, string) => unit = "exec"
|
|
16
12
|
@send external _prepare: (t, string) => statement = "prepare"
|
|
@@ -25,7 +21,7 @@ let _iterateApply: (statement, array<JSON.t>) => Iterator.t<dict<JSON.t>> = %raw
|
|
|
25
21
|
|
|
26
22
|
let openDb = (~path) => {
|
|
27
23
|
if path !== ":memory:" {
|
|
28
|
-
mkdirSync(dirname(path), {recursive: true})
|
|
24
|
+
NodeFs.mkdirSync(NodePath.dirname(path), {recursive: true})
|
|
29
25
|
}
|
|
30
26
|
let db = openDatabaseSync(path)
|
|
31
27
|
// Connection pragmas. WAL lets readers proceed concurrently with a writer and
|
|
@@ -10,13 +10,12 @@
|
|
|
10
10
|
|
|
11
11
|
open JestGlobals
|
|
12
12
|
|
|
13
|
-
@val external processEnv: dict<string> = "process.env"
|
|
14
13
|
|
|
15
14
|
let _ = TestRunner.setup()
|
|
16
15
|
let opts: Pulumi.CustomResourceOptions.t = {}
|
|
17
16
|
|
|
18
17
|
let pgPool =
|
|
19
|
-
|
|
18
|
+
NodeProcess.env
|
|
20
19
|
->Dict.get("PG_URL")
|
|
21
20
|
->Option.map(url => ReventlessPostgres.PgDriver.makePool({connectionString: url}))
|
|
22
21
|
|
|
@@ -168,6 +167,100 @@ describe("Backend parity (Memory vs Sqlite)", () => {
|
|
|
168
167
|
await runUnderSqlite(scenario)
|
|
169
168
|
})
|
|
170
169
|
|
|
170
|
+
testPromise("QueryDb: a first save is Added and a second is Updated under both", async () => {
|
|
171
|
+
let scenario = async () => {
|
|
172
|
+
module TestBus = LocalBus.Make()
|
|
173
|
+
module Storage = LocalQueryDbStorage.Make(TestBus)
|
|
174
|
+
let s = Storage.make(~name="parity-kind", ~indexes=[], ~api=(), ~apiRole=(), ~owner=None, ~opts)
|
|
175
|
+
let ops = await s.operations->TestRunner.resolve
|
|
176
|
+
|
|
177
|
+
let kinds = []
|
|
178
|
+
TestBus.subscribeToStateChanges("parity-kind", descriptor =>
|
|
179
|
+
descriptor
|
|
180
|
+
->JSON.Decode.object
|
|
181
|
+
->Option.flatMap(o => o->Dict.get("changeKind"))
|
|
182
|
+
->Option.flatMap(JSON.Decode.string)
|
|
183
|
+
->Option.forEach(k => kinds->Array.push(k))
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
let item = (k, v) =>
|
|
187
|
+
JSON.Encode.object(
|
|
188
|
+
Dict.fromArray([("id", JSON.Encode.string(k)), ("v", JSON.Encode.string(v))]),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
// The distinction a list view depends on: a row it has never seen arrives
|
|
192
|
+
// as Added (and is inserted), a row it holds arrives as Updated.
|
|
193
|
+
let _ = await ops.save("p1", item("p1", "one"), ReventlessCore.QueryDb.Any, None)
|
|
194
|
+
let _ = await ops.save("p1", item("p1", "two"), ReventlessCore.QueryDb.Any, None)
|
|
195
|
+
// A distinct key is its own first insert, not an update of the store.
|
|
196
|
+
let _ = await ops.save("p2", item("p2", "one"), ReventlessCore.QueryDb.Any, None)
|
|
197
|
+
// Deleting frees the key, so saving it again is an insert once more.
|
|
198
|
+
let _ = await ops.delete("p1", None)
|
|
199
|
+
let _ = await ops.save("p1", item("p1", "three"), ReventlessCore.QueryDb.Any, None)
|
|
200
|
+
|
|
201
|
+
expect(kinds)->toEqual(["Added", "Updated", "Added", "Removed", "Added"])
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await runUnderMemory(scenario)
|
|
205
|
+
await runUnderSqlite(scenario)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
testPromise("QueryDb: saveBatch reports Added then Updated for a repeated key under both", async () => {
|
|
209
|
+
let scenario = async () => {
|
|
210
|
+
module TestBus = LocalBus.Make()
|
|
211
|
+
module Storage = LocalQueryDbStorage.Make(TestBus)
|
|
212
|
+
let s = Storage.make(~name="parity-batch", ~indexes=[], ~api=(), ~apiRole=(), ~owner=None, ~opts)
|
|
213
|
+
let ops = await s.operations->TestRunner.resolve
|
|
214
|
+
|
|
215
|
+
let kinds = []
|
|
216
|
+
TestBus.subscribeToStateChanges("parity-batch", descriptor =>
|
|
217
|
+
descriptor
|
|
218
|
+
->JSON.Decode.object
|
|
219
|
+
->Option.flatMap(o => o->Dict.get("changeKind"))
|
|
220
|
+
->Option.flatMap(JSON.Decode.string)
|
|
221
|
+
->Option.forEach(k => kinds->Array.push(k))
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
let item = k => JSON.Encode.object(Dict.fromArray([("id", JSON.Encode.string(k))]))
|
|
225
|
+
let _ = await ops.saveBatch([
|
|
226
|
+
("a", item("a"), None),
|
|
227
|
+
("b", item("b"), None),
|
|
228
|
+
("a", item("a"), None),
|
|
229
|
+
])
|
|
230
|
+
|
|
231
|
+
expect(kinds)->toEqual(["Added", "Added", "Updated"])
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await runUnderMemory(scenario)
|
|
235
|
+
await runUnderSqlite(scenario)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
testPromise("QueryDb: a counter's first increment is Added under both", async () => {
|
|
239
|
+
let scenario = async () => {
|
|
240
|
+
module TestBus = LocalBus.Make()
|
|
241
|
+
module Storage = LocalQueryDbStorage.Make(TestBus)
|
|
242
|
+
let s = Storage.make(~name="parity-ckind", ~indexes=[], ~api=(), ~apiRole=(), ~owner=None, ~opts)
|
|
243
|
+
let ops = await s.operations->TestRunner.resolve
|
|
244
|
+
|
|
245
|
+
let kinds = []
|
|
246
|
+
TestBus.subscribeToStateChanges("parity-ckind", descriptor =>
|
|
247
|
+
descriptor
|
|
248
|
+
->JSON.Decode.object
|
|
249
|
+
->Option.flatMap(o => o->Dict.get("changeKind"))
|
|
250
|
+
->Option.flatMap(JSON.Decode.string)
|
|
251
|
+
->Option.forEach(k => kinds->Array.push(k))
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
let _ = await ops.count("prod-1", "orderCount", 1)
|
|
255
|
+
let _ = await ops.count("prod-1", "orderCount", 1)
|
|
256
|
+
|
|
257
|
+
expect(kinds)->toEqual(["Added", "Updated"])
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await runUnderMemory(scenario)
|
|
261
|
+
await runUnderSqlite(scenario)
|
|
262
|
+
})
|
|
263
|
+
|
|
171
264
|
testPromise("EventLog: conflict detection works under both", async () => {
|
|
172
265
|
let scenario = async () => {
|
|
173
266
|
module TestBus = LocalBus.Make()
|
|
@@ -137,6 +137,102 @@ globalThis.describe("Backend parity (Memory vs Sqlite)", () => {
|
|
|
137
137
|
await runUnderMemory(scenario);
|
|
138
138
|
return await runUnderSqlite(scenario);
|
|
139
139
|
});
|
|
140
|
+
globalThis.test("QueryDb: a first save is Added and a second is Updated under both", async () => {
|
|
141
|
+
let scenario = async () => {
|
|
142
|
+
let TestBus = LocalBus$ReventlessLocal.Make({});
|
|
143
|
+
let Storage = LocalQueryDbStorage$ReventlessLocal.Make(TestBus);
|
|
144
|
+
let s = Storage.make("parity-kind", [], undefined, undefined, undefined, undefined, undefined, opts);
|
|
145
|
+
let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
|
|
146
|
+
let kinds = [];
|
|
147
|
+
TestBus.subscribeToStateChanges("parity-kind", descriptor => Stdlib_Option.forEach(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(descriptor), o => o["changeKind"]), Stdlib_JSON.Decode.string), k => {
|
|
148
|
+
kinds.push(k);
|
|
149
|
+
}));
|
|
150
|
+
let item = (k, v) => Object.fromEntries([
|
|
151
|
+
[
|
|
152
|
+
"id",
|
|
153
|
+
k
|
|
154
|
+
],
|
|
155
|
+
[
|
|
156
|
+
"v",
|
|
157
|
+
v
|
|
158
|
+
]
|
|
159
|
+
]);
|
|
160
|
+
await ops.save("p1", item("p1", "one"), "Any", undefined);
|
|
161
|
+
await ops.save("p1", item("p1", "two"), "Any", undefined);
|
|
162
|
+
await ops.save("p2", item("p2", "one"), "Any", undefined);
|
|
163
|
+
await ops.delete("p1", undefined);
|
|
164
|
+
await ops.save("p1", item("p1", "three"), "Any", undefined);
|
|
165
|
+
globalThis.expect(kinds).toEqual([
|
|
166
|
+
"Added",
|
|
167
|
+
"Updated",
|
|
168
|
+
"Added",
|
|
169
|
+
"Removed",
|
|
170
|
+
"Added"
|
|
171
|
+
]);
|
|
172
|
+
};
|
|
173
|
+
await runUnderMemory(scenario);
|
|
174
|
+
return await runUnderSqlite(scenario);
|
|
175
|
+
});
|
|
176
|
+
globalThis.test("QueryDb: saveBatch reports Added then Updated for a repeated key under both", async () => {
|
|
177
|
+
let scenario = async () => {
|
|
178
|
+
let TestBus = LocalBus$ReventlessLocal.Make({});
|
|
179
|
+
let Storage = LocalQueryDbStorage$ReventlessLocal.Make(TestBus);
|
|
180
|
+
let s = Storage.make("parity-batch", [], undefined, undefined, undefined, undefined, undefined, opts);
|
|
181
|
+
let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
|
|
182
|
+
let kinds = [];
|
|
183
|
+
TestBus.subscribeToStateChanges("parity-batch", descriptor => Stdlib_Option.forEach(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(descriptor), o => o["changeKind"]), Stdlib_JSON.Decode.string), k => {
|
|
184
|
+
kinds.push(k);
|
|
185
|
+
}));
|
|
186
|
+
let item = k => Object.fromEntries([[
|
|
187
|
+
"id",
|
|
188
|
+
k
|
|
189
|
+
]]);
|
|
190
|
+
await ops.saveBatch([
|
|
191
|
+
[
|
|
192
|
+
"a",
|
|
193
|
+
item("a"),
|
|
194
|
+
undefined
|
|
195
|
+
],
|
|
196
|
+
[
|
|
197
|
+
"b",
|
|
198
|
+
item("b"),
|
|
199
|
+
undefined
|
|
200
|
+
],
|
|
201
|
+
[
|
|
202
|
+
"a",
|
|
203
|
+
item("a"),
|
|
204
|
+
undefined
|
|
205
|
+
]
|
|
206
|
+
]);
|
|
207
|
+
globalThis.expect(kinds).toEqual([
|
|
208
|
+
"Added",
|
|
209
|
+
"Added",
|
|
210
|
+
"Updated"
|
|
211
|
+
]);
|
|
212
|
+
};
|
|
213
|
+
await runUnderMemory(scenario);
|
|
214
|
+
return await runUnderSqlite(scenario);
|
|
215
|
+
});
|
|
216
|
+
globalThis.test("QueryDb: a counter's first increment is Added under both", async () => {
|
|
217
|
+
let scenario = async () => {
|
|
218
|
+
let TestBus = LocalBus$ReventlessLocal.Make({});
|
|
219
|
+
let Storage = LocalQueryDbStorage$ReventlessLocal.Make(TestBus);
|
|
220
|
+
let s = Storage.make("parity-ckind", [], undefined, undefined, undefined, undefined, undefined, opts);
|
|
221
|
+
let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
|
|
222
|
+
let kinds = [];
|
|
223
|
+
TestBus.subscribeToStateChanges("parity-ckind", descriptor => Stdlib_Option.forEach(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(descriptor), o => o["changeKind"]), Stdlib_JSON.Decode.string), k => {
|
|
224
|
+
kinds.push(k);
|
|
225
|
+
}));
|
|
226
|
+
await ops.count("prod-1", "orderCount", 1);
|
|
227
|
+
await ops.count("prod-1", "orderCount", 1);
|
|
228
|
+
globalThis.expect(kinds).toEqual([
|
|
229
|
+
"Added",
|
|
230
|
+
"Updated"
|
|
231
|
+
]);
|
|
232
|
+
};
|
|
233
|
+
await runUnderMemory(scenario);
|
|
234
|
+
return await runUnderSqlite(scenario);
|
|
235
|
+
});
|
|
140
236
|
globalThis.test("EventLog: conflict detection works under both", async () => {
|
|
141
237
|
let scenario = async () => {
|
|
142
238
|
let TestBus = LocalBus$ReventlessLocal.Make({});
|
|
@@ -17,7 +17,6 @@ let defaultMeta: Reventless.Message.meta = {
|
|
|
17
17
|
correlationId: "",
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
@val external processEnv: Dict.t<string> = "process.env"
|
|
21
20
|
|
|
22
21
|
type consoleObj = {mutable log: string => unit}
|
|
23
22
|
@val external console: consoleObj = "console"
|
|
@@ -48,7 +47,7 @@ let tapLines = (logs: array<string>): array<string> =>
|
|
|
48
47
|
|
|
49
48
|
describe("LocalBus event tap (Phase 9)", () => {
|
|
50
49
|
// Each test owns the env flag; always clear it afterwards so cases don't leak.
|
|
51
|
-
let clearEnv = () =>
|
|
50
|
+
let clearEnv = () => NodeProcess.env->Dict.delete("REVENTLESS_EVENT_TAP")
|
|
52
51
|
|
|
53
52
|
testPromise("tap off → no sentinel line is emitted", async () => {
|
|
54
53
|
clearEnv()
|
|
@@ -60,7 +59,7 @@ describe("LocalBus event tap (Phase 9)", () => {
|
|
|
60
59
|
})
|
|
61
60
|
|
|
62
61
|
testPromise("tap on → one well-formed line per published event", async () => {
|
|
63
|
-
|
|
62
|
+
NodeProcess.env->Dict.set("REVENTLESS_EVENT_TAP", "ndjson")
|
|
64
63
|
module TestBus = LocalBus.MakeSilent()
|
|
65
64
|
let logs = await captureLogs(async () => {
|
|
66
65
|
await TestBus.publishEvent(
|
|
@@ -90,7 +89,7 @@ describe("LocalBus event tap (Phase 9)", () => {
|
|
|
90
89
|
})
|
|
91
90
|
|
|
92
91
|
testPromise("tap emits even when the topic has no subscribers", async () => {
|
|
93
|
-
|
|
92
|
+
NodeProcess.env->Dict.set("REVENTLESS_EVENT_TAP", "ndjson")
|
|
94
93
|
module TestBus = LocalBus.MakeSilent()
|
|
95
94
|
let logs = await captureLogs(async () => {
|
|
96
95
|
// No subscribeToEvents call — the tap fires before the subscriber-count gate.
|
|
@@ -4,11 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
open JestGlobals
|
|
6
6
|
|
|
7
|
-
@module("node:fs") external _writeFileSync: (string, string, string) => unit = "writeFileSync"
|
|
8
|
-
@module("node:fs") external _mkdtempSync: string => string = "mkdtempSync"
|
|
9
|
-
@module("node:fs") external _rmSync: (string, {..}) => unit = "rmSync"
|
|
10
|
-
@module("node:path") external _join: (string, string) => string = "join"
|
|
11
|
-
@module("node:os") external _tmpdir: unit => string = "tmpdir"
|
|
12
7
|
|
|
13
8
|
let resetAll = () => {
|
|
14
9
|
LocalAuth.resetUsers()
|
|
@@ -17,9 +12,9 @@ let resetAll = () => {
|
|
|
17
12
|
}
|
|
18
13
|
|
|
19
14
|
let _writeYamlFile = (contents: string): string => {
|
|
20
|
-
let dir =
|
|
21
|
-
let path =
|
|
22
|
-
|
|
15
|
+
let dir = NodeFs.mkdtempSync(NodePath.join([NodeOs.tmpdir(), "reventless-userstore-"]))
|
|
16
|
+
let path = NodePath.join([dir, "users.yaml"])
|
|
17
|
+
NodeFs.writeFileSync(path, contents)
|
|
23
18
|
path
|
|
24
19
|
}
|
|
25
20
|
|
|
@@ -102,7 +97,7 @@ testPromise("load(~usersFile) reads a YAML file from disk", async () => {
|
|
|
102
97
|
| Ok(_) => ()
|
|
103
98
|
| Error(msg) => JsError.throwWithMessage("issue rejected file-loaded credentials: " ++ msg)
|
|
104
99
|
}
|
|
105
|
-
|
|
100
|
+
NodeFs.rmSync(path, {force: true})
|
|
106
101
|
})
|
|
107
102
|
|
|
108
103
|
testPromise("load(~usersFile) returns Error when the file is missing", async () => {
|
|
@@ -152,7 +147,7 @@ testPromise("inline users win over a file path", async () => {
|
|
|
152
147
|
| Some(_) => ()
|
|
153
148
|
| None => JsError.throwWithMessage("inline-user missing from registry")
|
|
154
149
|
}
|
|
155
|
-
|
|
150
|
+
NodeFs.rmSync(path, {force: true})
|
|
156
151
|
})
|
|
157
152
|
|
|
158
153
|
testPromise("autoLoadOnce is a no-op after an explicit load", async () => {
|
|
@@ -120,6 +120,36 @@ describe("LocalEvents_Server", () => {
|
|
|
120
120
|
expect(sentFrames.contents->Array.length)->toBe(2)
|
|
121
121
|
})
|
|
122
122
|
|
|
123
|
+
testSync("publishes on the plugin-qualified list field, not the store name", () => {
|
|
124
|
+
// The channel root the client subscribes on is the read model's
|
|
125
|
+
// `listFieldName` ("Catalog_Products"), not its store name ("Products").
|
|
126
|
+
// The publish must translate through the same registry the AWS StateTopic
|
|
127
|
+
// wiring uses — otherwise the descriptor lands on a channel no one listens
|
|
128
|
+
// to and the socket stays Open while no view refreshes.
|
|
129
|
+
let registry = ReventlessCore.Plugin_Helpers.queryFieldNamesRegistry
|
|
130
|
+
registry->Dict.set(
|
|
131
|
+
"Products",
|
|
132
|
+
{
|
|
133
|
+
ReventlessCore.Api_Naming.singleFieldName: "Catalog_Product",
|
|
134
|
+
listFieldName: "Catalog_Products",
|
|
135
|
+
returnTypeName: "Catalog_Product",
|
|
136
|
+
pluralTypeName: "Catalog_Products",
|
|
137
|
+
includeIdParam: false,
|
|
138
|
+
connectionSpec: true,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
let conn = makeConn()
|
|
142
|
+
LocalEvents_Server.handleFrame(
|
|
143
|
+
conn,
|
|
144
|
+
`{"type":"subscribe","id":"s","channel":"/default/Catalog-Products/*"}`,
|
|
145
|
+
)
|
|
146
|
+
LocalEvents_Server.broadcastStateChange(~name="Products", ~descriptor=descriptor(~id="SKU-1"))
|
|
147
|
+
// subscribe_success + a data frame ⇒ the descriptor reached the client's channel
|
|
148
|
+
expect(sentFrames.contents->Array.length)->toBe(2)
|
|
149
|
+
expect(sentFrames.contents->Array.getUnsafe(1)->frameField("type"))->toEqual(Some("data"))
|
|
150
|
+
registry->Dict.delete("Products")
|
|
151
|
+
})
|
|
152
|
+
|
|
123
153
|
testSync("unsubscribe stops delivery", () => {
|
|
124
154
|
let conn = makeConn()
|
|
125
155
|
LocalEvents_Server.handleFrame(
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
3
4
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
6
|
import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
|
|
7
|
+
import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
|
|
6
8
|
import * as LocalEvents_Server$ReventlessLocal from "../../src/adapter/Api/LocalEvents_Server.res.mjs";
|
|
7
9
|
|
|
8
10
|
TestRunner$ReventlessLocal.setup();
|
|
@@ -85,6 +87,22 @@ globalThis.describe("LocalEvents_Server", () => {
|
|
|
85
87
|
LocalEvents_Server$ReventlessLocal.broadcastStateChange("My.Model", descriptor("order#1@2026"));
|
|
86
88
|
globalThis.expect(sentFrames.contents.length).toBe(2);
|
|
87
89
|
});
|
|
90
|
+
globalThis.test("publishes on the plugin-qualified list field, not the store name", () => {
|
|
91
|
+
Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry["Products"] = {
|
|
92
|
+
singleFieldName: "Catalog_Product",
|
|
93
|
+
listFieldName: "Catalog_Products",
|
|
94
|
+
returnTypeName: "Catalog_Product",
|
|
95
|
+
pluralTypeName: "Catalog_Products",
|
|
96
|
+
includeIdParam: false,
|
|
97
|
+
connectionSpec: true
|
|
98
|
+
};
|
|
99
|
+
let conn = makeConn();
|
|
100
|
+
LocalEvents_Server$ReventlessLocal.handleFrame(conn, `{"type":"subscribe","id":"s","channel":"/default/Catalog-Products/*"}`);
|
|
101
|
+
LocalEvents_Server$ReventlessLocal.broadcastStateChange("Products", descriptor("SKU-1"));
|
|
102
|
+
globalThis.expect(sentFrames.contents.length).toBe(2);
|
|
103
|
+
globalThis.expect(frameField(sentFrames.contents[1], "type")).toEqual("data");
|
|
104
|
+
Stdlib_Dict.$$delete(Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry, "Products");
|
|
105
|
+
});
|
|
88
106
|
globalThis.test("unsubscribe stops delivery", () => {
|
|
89
107
|
let conn = makeConn();
|
|
90
108
|
LocalEvents_Server$ReventlessLocal.handleFrame(conn, `{"type":"subscribe","id":"sub-1","channel":"/default/Product/*"}`);
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
|
|
11
11
|
open JestGlobals
|
|
12
12
|
|
|
13
|
-
@val external processEnv: dict<string> = "process.env"
|
|
14
13
|
let opts: Pulumi.CustomResourceOptions.t = {}
|
|
15
14
|
|
|
16
15
|
// Flat stored aggregate event — the exact shape EventLog_Operations.encodeEvent'
|
|
@@ -58,7 +57,7 @@ let recorder = () => {
|
|
|
58
57
|
(received, handler)
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
switch
|
|
60
|
+
switch NodeProcess.env->Dict.get("PG_URL") {
|
|
62
61
|
| None => testSync("Postgres projection catch-up (skipped — set PG_URL)", () => expect(true)->toBe(true))
|
|
63
62
|
| Some(url) =>
|
|
64
63
|
let pool = ReventlessPostgres.PgDriver.makePool({connectionString: url})
|
|
@@ -64,23 +64,23 @@ describe("SqliteDriver", () => {
|
|
|
64
64
|
|
|
65
65
|
testPromise("Backend.fromEnv parses sqlite path and reset flag", async () => {
|
|
66
66
|
let originalEnv =
|
|
67
|
-
|
|
67
|
+
NodeProcess.env->Dict.get("REVENTLESS_LOCAL_BACKEND")
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
NodeProcess.env->Dict.set("REVENTLESS_LOCAL_BACKEND", "sqlite:./local.db")
|
|
70
70
|
let parsed = Backend.fromEnv()
|
|
71
71
|
expect(parsed)->toEqual(Backend.Sqlite({path: "./local.db", resetOnStart: false}))
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
NodeProcess.env->Dict.set("REVENTLESS_LOCAL_BACKEND", "sqlite:./local.db?reset")
|
|
74
74
|
let parsedReset = Backend.fromEnv()
|
|
75
75
|
expect(parsedReset)->toEqual(Backend.Sqlite({path: "./local.db", resetOnStart: true}))
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
NodeProcess.env->Dict.set("REVENTLESS_LOCAL_BACKEND", "memory")
|
|
78
78
|
let parsedMem = Backend.fromEnv()
|
|
79
79
|
expect(parsedMem)->toEqual(Backend.Memory)
|
|
80
80
|
|
|
81
81
|
switch originalEnv {
|
|
82
|
-
| Some(v) =>
|
|
83
|
-
| None =>
|
|
82
|
+
| Some(v) => NodeProcess.env->Dict.set("REVENTLESS_LOCAL_BACKEND", v)
|
|
83
|
+
| None => NodeProcess.env->Dict.delete("REVENTLESS_LOCAL_BACKEND")
|
|
84
84
|
}
|
|
85
85
|
})
|
|
86
86
|
})
|