@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 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.182",
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.31",
38
- "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.25",
39
- "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.20",
40
- "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
41
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
42
- "@reventlessdev/reventless-core": "3.0.0-alpha.194",
43
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.141",
44
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.42",
45
- "@reventlessdev/reventless-infra": "3.0.0-alpha.112",
46
- "@reventlessdev/reventless-spec": "3.0.0-alpha.87",
47
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.58"
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
@@ -24,6 +24,7 @@
24
24
  ],
25
25
  "dependencies": [
26
26
  "sury",
27
+ "@reventlessdev/rescript-node",
27
28
  "@reventlessdev/rescript-jest",
28
29
  "@reventlessdev/rescript-effect",
29
30
  "@reventlessdev/rescript-pulumi-pulumi",
@@ -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
- processExit(1)
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
- processExit(1)
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
- processExit(0)
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
- @val external processEnv: dict<string> = "process.env"
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
- processEnv->Dict.get(key)->Option.flatMap(v => Int.fromString(v))->Option.getOr(fallback)
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
- let channel = `/default/${pathSegment(name)}/${pathSegment(entityKey)}`
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 channel = `/default/` + pathSegment(name) + `/` + pathSegment(entityKey);
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 _processEnv->Dict.get("REVENTLESS_INMEMORY_TOKEN_SECRET") {
104
+ let s = switch NodeProcess.env->Dict.get("REVENTLESS_INMEMORY_TOKEN_SECRET") {
114
105
  | Some(envSecret) if String.length(envSecret) >= 16 => envSecret
115
- | _ => _randomBytes(32)->_bufToString("hex")
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
- _createHmac("sha256", _getSecret())->_update(payload)->_digest("base64url")
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 = _readFileSync(path, "utf8")
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 => _join(_processCwd(), ".reventless/users.yaml")
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 _existsSync(defaultPath) {
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 = Nodepath.join(process.cwd(), ".reventless/users.yaml");
109
+ let defaultPath = _defaultPath();
110
110
  if (Nodefs.existsSync(defaultPath)) {
111
111
  return Stdlib_Result.map(parseFile(defaultPath), entries => {
112
112
  _registerEntries(entries);
@@ -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 _unlinkSync(path) catch {
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 processEnv->Dict.get("REVENTLESS_LOCAL_BACKEND") {
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
- @val external processEnv: dict<string> = "process.env"
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 + `/` + Crypto.randomUUID() + `/` + fileName;
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
- @val external processEnv: dict<string> = "process.env"
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.
@@ -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". In-memory adapters
72
- typically emit "Updated" for save() (no Added detection — see module-type
73
- docstring) and "Removed" for delete().
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 = () => tapProcessEnv->Dict.get("REVENTLESS_EVENT_TAP")->Option.isSome
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
- // In-memory degradation vs AWS: `changeKind` for save() is always "Updated"
214
- // (AWS distinguishes "Added" via DynamoDB streams' INSERT eventName; the
215
- // in-memory adapter doesn't cheaply track first-insert). `delete()` emits
216
- // "Removed". `position` is omitted (Phase 3 deferred).
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
- @val external processEnv: dict<string> = "process.env"
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 publishUpdated = (id: string, state: JSON.t) => {
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="Updated",
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
- publishUpdated(id, state)
220
+ publishSaved(~changeKind, id, state)
202
221
  Ok()
203
222
  }
204
223
 
205
224
  let saveBatch: QueryDb.saveBatch<string, JSON.t> = async batch => {
206
- batch->Array.forEach(((id, state, ttl)) => {
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.forEach(((id, state, _)) => publishUpdated(id, state))
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
- publishUpdated(id, newItem)
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 publishUpdated = (id, state) => {
168
+ let publishSaved = (changeKind, id, state) => {
154
169
  let subKey = getSubKey(state, subIdField);
155
- let descriptor = LocalBus$ReventlessLocal.makeStateChangeDescriptor("Updated", entityKeyFor(id, subKey), state);
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
- publishUpdated(id, state);
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.forEach(param => {
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 => publishUpdated(param[0], param[1]));
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
- publishUpdated(id, obj);
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 publishUpdated = (id: string, state: JSON.t) => {
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="Updated",
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
- publishUpdated(id, state)
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)) => saveOne(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
- publishUpdated(id, newItem)
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 publishUpdated = (id, state) => {
195
+ let publishSaved = (changeKind, id, state) => {
195
196
  let subKey = computeSubKey(state, subIdField);
196
- let descriptor = LocalBus$ReventlessLocal.makeStateChangeDescriptor("Updated", entityKeyFor(id, subKey), state);
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
- publishUpdated(id, state);
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 => saveOne(param[0], param[1], param[2]));
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 => publishUpdated(param[0], param[1]));
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
- publishUpdated(id, obj);
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
- processEnv
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 = () => processEnv->Dict.delete("REVENTLESS_EVENT_TAP")
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
- processEnv->Dict.set("REVENTLESS_EVENT_TAP", "ndjson")
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
- processEnv->Dict.set("REVENTLESS_EVENT_TAP", "ndjson")
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 = _mkdtempSync(_join(_tmpdir(), "reventless-userstore-"))
21
- let path = _join(dir, "users.yaml")
22
- _writeFileSync(path, contents, "utf8")
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
- _rmSync(path, {"force": true})
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
- _rmSync(path, {"force": true})
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 processEnv->Dict.get("PG_URL") {
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
- Backend.processEnv->Dict.get("REVENTLESS_LOCAL_BACKEND")
67
+ NodeProcess.env->Dict.get("REVENTLESS_LOCAL_BACKEND")
68
68
 
69
- Backend.processEnv->Dict.set("REVENTLESS_LOCAL_BACKEND", "sqlite:./local.db")
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
- Backend.processEnv->Dict.set("REVENTLESS_LOCAL_BACKEND", "sqlite:./local.db?reset")
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
- Backend.processEnv->Dict.set("REVENTLESS_LOCAL_BACKEND", "memory")
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) => Backend.processEnv->Dict.set("REVENTLESS_LOCAL_BACKEND", v)
83
- | None => Backend.processEnv->Dict.delete("REVENTLESS_LOCAL_BACKEND")
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
  })