@reventlessdev/reventless-local 3.0.0-alpha.181 → 3.0.0-alpha.183

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.183 (2026-07-30)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **local:** a row saved for the first time is Added, not Updated ([6947253](https://github.com/ReventlessDev/reventless-core/commit/6947253a94f9e5fdfeb42fd9268795c948871803))
11
+ * **local:** publish live descriptors on the list-field channel clients subscribe to ([912940d](https://github.com/ReventlessDev/reventless-core/commit/912940dbd5d180b4b3222e5cb954362be2671594))
12
+
13
+
14
+ # 3.0.0-alpha.182 (2026-07-30)
15
+
16
+ **Note:** Version bump only for package @reventlessdev/reventless-local
17
+
18
+
19
+
20
+
21
+
6
22
  # 3.0.0-alpha.181 (2026-07-30)
7
23
 
8
24
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.181",
3
+ "version": "3.0.0-alpha.183",
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,17 @@
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
37
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.25",
39
38
  "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
40
- "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.20",
39
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.31",
41
40
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
42
- "@reventlessdev/reventless-core": "3.0.0-alpha.193",
43
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.140",
44
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.41",
45
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.57",
46
- "@reventlessdev/reventless-infra": "3.0.0-alpha.111",
47
- "@reventlessdev/reventless-spec": "3.0.0-alpha.86"
41
+ "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.20",
42
+ "@reventlessdev/reventless-core": "3.0.0-alpha.195",
43
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.142",
44
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.59",
45
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.88",
46
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.43",
47
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.113"
48
48
  },
49
49
  "devDependencies": {
50
50
  "rescript": "12.3.0",
@@ -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
 
@@ -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);
@@ -210,10 +210,12 @@ module type T = {
210
210
  // ~name is the QueryDb/ReadModel Spec.name; ~descriptor is built via
211
211
  // `makeStateChangeDescriptor` below.
212
212
  //
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).
213
+ // `changeKind` matches AWS, which reads it off the DynamoDB stream eventName:
214
+ // save() checks whether a visible row already held the key and emits "Added"
215
+ // or "Updated" accordingly, `delete()` emits "Removed". Without the "Added"
216
+ // arm a list view drops every row it doesn't already hold, so seeding into an
217
+ // empty read model looked like live updates were broken. `position` is
218
+ // omitted (Phase 3 deferred).
217
219
  let publishStateChange: (~name: string, ~descriptor: JSON.t) => unit
218
220
  let subscribeToStateChanges: (string, JSON.t => unit) => unit
219
221
  // All-changes variant: receives every publishStateChange with its read-model
@@ -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
@@ -168,6 +168,100 @@ describe("Backend parity (Memory vs Sqlite)", () => {
168
168
  await runUnderSqlite(scenario)
169
169
  })
170
170
 
171
+ testPromise("QueryDb: a first save is Added and a second is Updated under both", async () => {
172
+ let scenario = async () => {
173
+ module TestBus = LocalBus.Make()
174
+ module Storage = LocalQueryDbStorage.Make(TestBus)
175
+ let s = Storage.make(~name="parity-kind", ~indexes=[], ~api=(), ~apiRole=(), ~owner=None, ~opts)
176
+ let ops = await s.operations->TestRunner.resolve
177
+
178
+ let kinds = []
179
+ TestBus.subscribeToStateChanges("parity-kind", descriptor =>
180
+ descriptor
181
+ ->JSON.Decode.object
182
+ ->Option.flatMap(o => o->Dict.get("changeKind"))
183
+ ->Option.flatMap(JSON.Decode.string)
184
+ ->Option.forEach(k => kinds->Array.push(k))
185
+ )
186
+
187
+ let item = (k, v) =>
188
+ JSON.Encode.object(
189
+ Dict.fromArray([("id", JSON.Encode.string(k)), ("v", JSON.Encode.string(v))]),
190
+ )
191
+
192
+ // The distinction a list view depends on: a row it has never seen arrives
193
+ // as Added (and is inserted), a row it holds arrives as Updated.
194
+ let _ = await ops.save("p1", item("p1", "one"), ReventlessCore.QueryDb.Any, None)
195
+ let _ = await ops.save("p1", item("p1", "two"), ReventlessCore.QueryDb.Any, None)
196
+ // A distinct key is its own first insert, not an update of the store.
197
+ let _ = await ops.save("p2", item("p2", "one"), ReventlessCore.QueryDb.Any, None)
198
+ // Deleting frees the key, so saving it again is an insert once more.
199
+ let _ = await ops.delete("p1", None)
200
+ let _ = await ops.save("p1", item("p1", "three"), ReventlessCore.QueryDb.Any, None)
201
+
202
+ expect(kinds)->toEqual(["Added", "Updated", "Added", "Removed", "Added"])
203
+ }
204
+
205
+ await runUnderMemory(scenario)
206
+ await runUnderSqlite(scenario)
207
+ })
208
+
209
+ testPromise("QueryDb: saveBatch reports Added then Updated for a repeated key under both", async () => {
210
+ let scenario = async () => {
211
+ module TestBus = LocalBus.Make()
212
+ module Storage = LocalQueryDbStorage.Make(TestBus)
213
+ let s = Storage.make(~name="parity-batch", ~indexes=[], ~api=(), ~apiRole=(), ~owner=None, ~opts)
214
+ let ops = await s.operations->TestRunner.resolve
215
+
216
+ let kinds = []
217
+ TestBus.subscribeToStateChanges("parity-batch", descriptor =>
218
+ descriptor
219
+ ->JSON.Decode.object
220
+ ->Option.flatMap(o => o->Dict.get("changeKind"))
221
+ ->Option.flatMap(JSON.Decode.string)
222
+ ->Option.forEach(k => kinds->Array.push(k))
223
+ )
224
+
225
+ let item = k => JSON.Encode.object(Dict.fromArray([("id", JSON.Encode.string(k))]))
226
+ let _ = await ops.saveBatch([
227
+ ("a", item("a"), None),
228
+ ("b", item("b"), None),
229
+ ("a", item("a"), None),
230
+ ])
231
+
232
+ expect(kinds)->toEqual(["Added", "Added", "Updated"])
233
+ }
234
+
235
+ await runUnderMemory(scenario)
236
+ await runUnderSqlite(scenario)
237
+ })
238
+
239
+ testPromise("QueryDb: a counter's first increment is Added under both", async () => {
240
+ let scenario = async () => {
241
+ module TestBus = LocalBus.Make()
242
+ module Storage = LocalQueryDbStorage.Make(TestBus)
243
+ let s = Storage.make(~name="parity-ckind", ~indexes=[], ~api=(), ~apiRole=(), ~owner=None, ~opts)
244
+ let ops = await s.operations->TestRunner.resolve
245
+
246
+ let kinds = []
247
+ TestBus.subscribeToStateChanges("parity-ckind", descriptor =>
248
+ descriptor
249
+ ->JSON.Decode.object
250
+ ->Option.flatMap(o => o->Dict.get("changeKind"))
251
+ ->Option.flatMap(JSON.Decode.string)
252
+ ->Option.forEach(k => kinds->Array.push(k))
253
+ )
254
+
255
+ let _ = await ops.count("prod-1", "orderCount", 1)
256
+ let _ = await ops.count("prod-1", "orderCount", 1)
257
+
258
+ expect(kinds)->toEqual(["Added", "Updated"])
259
+ }
260
+
261
+ await runUnderMemory(scenario)
262
+ await runUnderSqlite(scenario)
263
+ })
264
+
171
265
  testPromise("EventLog: conflict detection works under both", async () => {
172
266
  let scenario = async () => {
173
267
  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({});
@@ -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/*"}`);