@reventlessdev/reventless-core 3.0.0-alpha.135 → 3.0.0-alpha.136

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,13 @@
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.136 (2026-07-02)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core,local:** projection/publisher/bus correctness (plan A5,A7) ([86e27a5](https://github.com/ReventlessDev/reventless-core/commit/86e27a5d68873d7dd9e746f9b4126f4d5bad63c9))
11
+
12
+
6
13
  # 3.0.0-alpha.135 (2026-06-29)
7
14
 
8
15
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.135",
3
+ "version": "3.0.0-alpha.136",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -28,17 +28,17 @@
28
28
  "dependencies": {
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
- "@reventlessdev/rescript-effect": "0.1.0-alpha.23",
32
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.10",
31
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.24",
33
32
  "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.11",
34
- "@reventlessdev/rescript-jest": "1.0.0-alpha.3",
33
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.5",
35
34
  "@reventlessdev/rescript-node-streams": "1.1.0-alpha.10",
35
+ "@reventlessdev/rescript-hash-object": "1.2.0-alpha.10",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
37
37
  "@reventlessdev/rescript-ssh2": "1.1.0-alpha.11",
38
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.83",
38
39
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.13",
39
- "@reventlessdev/reventless-interop": "3.0.0-alpha.21",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.62",
41
- "@reventlessdev/reventless-infra": "3.0.0-alpha.82"
40
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.22",
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.63"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "^12.3.0",
@@ -103,8 +103,16 @@ let applyChanges = async (
103
103
  `${action}(${id}): beforeStates:${beforeCount->Int.toString} afterStates:${afterCount->Int.toString} added:${addedCount->Int.toString} changed:${changedCount->Int.toString} deleted:${deletedCount->Int.toString}`,
104
104
  )
105
105
  let result = await [deleteBatch(batchToDelete), saveBatch(batchToSave)]->Promise.all
106
- switch result {
107
- | _ => Ok() // TODO: Error handling
106
+ // Propagate the first storage failure instead of swallowing it — a failed
107
+ // delete/save was previously reported as a successful projection.
108
+ switch result->Array.find(r =>
109
+ switch r {
110
+ | Error(_) => true
111
+ | Ok(_) => false
112
+ }
113
+ ) {
114
+ | Some(err) => err
115
+ | None => Ok()
108
116
  }
109
117
  }
110
118
 
@@ -236,10 +244,12 @@ let handleAction = async (
236
244
  logAction(`DeleteMany(${ids->Array.joinUnsafe(", ")})`)
237
245
  await deleteBatch(ids->Array.map(id => (id, None)))
238
246
 
239
- // TODO: add missing actions
247
+ // An action reaching here is one this projection engine doesn't implement.
248
+ // Returning Ok() reported the read-model write as done while silently losing
249
+ // it; surface a storage error instead.
240
250
  | _ =>
241
- logAction("Error: Action not yet supported !")
242
- Ok()
251
+ logAction("Error: projection action not supported")
252
+ Error(ReventlessInfra.QueryDb.NotSavedToStorage("unsupported projection action"))
243
253
  }
244
254
  }
245
255
 
@@ -285,11 +295,10 @@ let optimizeActions = actions => {
285
295
  [action]
286
296
  } else {
287
297
  let lastAction = optimizedActions->Array.getUnsafe(optimizedActionsCount - 1)
288
- let previousActions = if optimizedActionsCount == 1 {
289
- []
290
- } else {
291
- optimizedActions->Array.slice(~start=0, ~end=optimizedActionsCount)
292
- }
298
+ // Everything BEFORE the last action. `~end` is exclusive, so this must be
299
+ // `count - 1`; using `count` kept `lastAction` in the slice while the merged
300
+ // action was also appended, applying the last action twice.
301
+ let previousActions = optimizedActions->Array.slice(~start=0, ~end=optimizedActionsCount - 1)
293
302
 
294
303
  switch (lastAction, action) {
295
304
  // SINGLE STATES
@@ -174,14 +174,19 @@ async function applyChanges(action, id, beforeStates, afterStates, param, param$
174
174
  ]);
175
175
  let deletedCount = batchToDelete.length;
176
176
  logAction(action + `(` + id + `): beforeStates:` + beforeCount.toString() + ` afterStates:` + afterCount.toString() + ` added:` + addedCount.toString() + ` changed:` + changedCount.toString() + ` deleted:` + deletedCount.toString());
177
- await Promise.all([
177
+ let result = await Promise.all([
178
178
  param.deleteBatch(batchToDelete),
179
179
  param.saveBatch(batchToSave)
180
180
  ]);
181
- return {
182
- TAG: "Ok",
183
- _0: undefined
184
- };
181
+ let err = result.find(r => r.TAG !== "Ok");
182
+ if (err !== undefined) {
183
+ return err;
184
+ } else {
185
+ return {
186
+ TAG: "Ok",
187
+ _0: undefined
188
+ };
189
+ }
185
190
  }
186
191
 
187
192
  function stateToString(state) {
@@ -369,10 +374,13 @@ async function handleAction(action, operations, subIdConfig) {
369
374
  _0: "MissingSubIdConfig"
370
375
  };
371
376
  default:
372
- logAction("Error: Action not yet supported !");
377
+ logAction("Error: projection action not supported");
373
378
  return {
374
- TAG: "Ok",
375
- _0: undefined
379
+ TAG: "Error",
380
+ _0: {
381
+ TAG: "NotSavedToStorage",
382
+ _0: "unsupported projection action"
383
+ }
376
384
  };
377
385
  }
378
386
  }
@@ -456,7 +464,7 @@ function optimizeActions(actions) {
456
464
  return [action];
457
465
  }
458
466
  let lastAction = optimizedActions[optimizedActionsCount - 1 | 0];
459
- let previousActions = optimizedActionsCount === 1 ? [] : optimizedActions.slice(0, optimizedActionsCount);
467
+ let previousActions = optimizedActions.slice(0, optimizedActionsCount - 1 | 0);
460
468
  if (typeof lastAction === "object") {
461
469
  switch (lastAction.TAG) {
462
470
  case "Create" :
@@ -185,11 +185,14 @@ module Make = (
185
185
  ~appendedEventCount=generatedEvents'->Array.length,
186
186
  )
187
187
  EffectLogger.logInfo(~comp, `append: id=${idStr}`)->Effect.map(_ => Ok(perRef))
188
- | Error(msg) if msg->String.includes("conflict") =>
188
+ | Error(EventLog.Conflict) =>
189
+ // Signal the outer replay+re-decide retry loop (which matches
190
+ // Error(_)); the string is an internal marker, no longer a
191
+ // cross-component substring sentinel.
189
192
  EffectLogger.logWarn(~comp, `conflict: id=${idStr}, will retry`)->Effect.map(
190
- _ => Error(msg),
193
+ _ => Error("conflict"),
191
194
  )
192
- | Error(msg) =>
195
+ | Error(EventLog.StorageFailure(msg)) =>
193
196
  let perRef = reportFinalOutcomes(
194
197
  outcomes,
195
198
  ~entityId=idStr,
@@ -146,14 +146,15 @@ function Make(Spec) {
146
146
  }));
147
147
  }
148
148
  let msg = appendResult._0;
149
- if (msg.includes("conflict")) {
149
+ if (typeof msg !== "object") {
150
150
  return Effect.map(EffectLogger$ReventlessCore.logWarn(comp, undefined, `conflict: id=` + idStr + `, will retry`), () => ({
151
151
  TAG: "Error",
152
- _0: msg
152
+ _0: "conflict"
153
153
  }));
154
154
  }
155
- let perRef$1 = reportFinalOutcomes(outcomes, idStr, false, 0, msg);
156
- return Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `append failed: id=` + idStr + `: ` + msg), () => ({
155
+ let msg$1 = msg._0;
156
+ let perRef$1 = reportFinalOutcomes(outcomes, idStr, false, 0, msg$1);
157
+ return Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `append failed: id=` + idStr + `: ` + msg$1), () => ({
157
158
  TAG: "Ok",
158
159
  _0: perRef$1
159
160
  }));
@@ -7,7 +7,19 @@ type component<'operations> = Component.t<t, outputs, 'operations>
7
7
 
8
8
  exception ReplayError(string)
9
9
 
10
- type append<'id, 'event> = (int, 'id, array<'event>) => promise<result<unit, string>>
10
+ // Typed append outcome. `Conflict` is the optimistic-concurrency sentinel — the
11
+ // deliberate append-condition failure (seq_nr/position already taken) that the
12
+ // caller retries by re-reading and re-deciding. Every other failure is a
13
+ // `StorageFailure` carrying its message. This replaces the former string sentinel
14
+ // that was detected by substring-matching a pretty-printed Cause across three
15
+ // layers — a real error whose text happened to contain "conflict" would have
16
+ // been retried as an OCC conflict, and a misclassified conflict would surface as
17
+ // a permanent error.
18
+ type appendError =
19
+ | Conflict
20
+ | StorageFailure(string)
21
+
22
+ type append<'id, 'event> = (int, 'id, array<'event>) => promise<result<unit, appendError>>
11
23
  type replay<'id, 'event> = 'id => promise<array<'event>>
12
24
  type replayStream<'id, 'event> = 'id => Stream.t<'event, string, unit>
13
25
  type appendStream<'id, 'event> = (int, 'id, Stream.t<'event, string, unit>) => Effect.t<unit, string, unit>
@@ -16,14 +16,21 @@ module type T = {
16
16
  // Retry schedule for transient storage errors.
17
17
  // Exponential backoff: 100ms, ~200ms, ~400ms, ~800ms, ~1600ms — max 5 retries.
18
18
  // Only retries on recognised transient error messages; permanent errors propagate immediately.
19
- let isTransient = (msg: string) =>
20
- msg->String.includes("ThrottlingException") ||
21
- msg->String.includes("ProvisionedThroughputExceededException") ||
22
- msg->String.includes("ServiceUnavailable") ||
23
- msg->String.includes("RequestLimitExceeded") ||
24
- msg->String.includes("InternalServerError")
25
-
26
- let storageRetrySchedule: Schedule.t<(Duration.t, int), string, unit> =
19
+ // Retry only recognised transient storage failures — never a Conflict (a retry
20
+ // of the same append would just conflict again; the OCC retry happens a layer up
21
+ // via replay+re-decide).
22
+ let isTransient = (e: EventLog.appendError) =>
23
+ switch e {
24
+ | EventLog.Conflict => false
25
+ | StorageFailure(msg) =>
26
+ msg->String.includes("ThrottlingException") ||
27
+ msg->String.includes("ProvisionedThroughputExceededException") ||
28
+ msg->String.includes("ServiceUnavailable") ||
29
+ msg->String.includes("RequestLimitExceeded") ||
30
+ msg->String.includes("InternalServerError")
31
+ }
32
+
33
+ let storageRetrySchedule: Schedule.t<(Duration.t, int), EventLog.appendError, unit> =
27
34
  Schedule.exponential(Duration.millis(100))
28
35
  ->Schedule.jittered
29
36
  ->Schedule.intersect(Schedule.recurs(5))
@@ -112,44 +119,40 @@ module Make = (Spec: ReventlessInfra.EventLog.T, Ops: Ops with module Spec = Spe
112
119
  }
113
120
  }
114
121
 
115
- // append returns result<unit, string> — never throws.
116
- // Storage errors are retried with exponential backoff (up to 5 times for transient errors).
117
- // After exhausting retries, or on permanent errors, returns Error.
118
- let isConflict = (msg: string) => msg->String.includes("conflict")
119
-
122
+ // append returns result<unit, EventLog.appendError> — never throws.
123
+ // Transient storage failures are retried with exponential backoff (up to 5
124
+ // times); a Conflict is returned immediately (retried a layer up), and other
125
+ // failures propagate after retries are exhausted.
120
126
  let append = async (sequenceNr, id, events') => {
121
127
  let eventsJson = events'->encodeEvents'(id, sequenceNr)
122
128
  let idStr = id->Spec.Id.toString
123
- // Build an Effect that fails with a string on storage error (enabling retry)
129
+ // A thrown storage error becomes a StorageFailure; a returned Error keeps its
130
+ // typed variant. The Effect fails with the typed appendError so the schedule
131
+ // can retry only transient failures, then we collapse the outcome back into a
132
+ // result (no Cause stringification / substring matching).
124
133
  let storageEffect =
125
134
  Effect.tryPromise(
126
- ~catch=(err: unknown) => Util.Error.messageFromUnknown(err, "storage error"),
135
+ ~catch=(err: unknown) =>
136
+ EventLog.StorageFailure(Util.Error.messageFromUnknown(err, "storage error")),
127
137
  () => Ops.storage.append(sequenceNr, idStr, eventsJson),
128
138
  )
129
139
  ->Effect.flatMap(result =>
130
140
  switch result {
131
- | Ok(_) => Effect.succeed(())
132
- | Error(msg) => Effect.fail(msg)
141
+ | Ok(_) => Effect.succeed()
142
+ | Error(e) => Effect.fail(e)
133
143
  }
134
144
  )
135
145
  ->Effect.retry(storageRetrySchedule)
136
- let exit = await storageEffect->Effect.runPromiseExit
137
- if exit->Exit.isSuccess {
138
- await publishToEventTopic(id, events', eventsJson)
139
- } else {
140
- // Retry exhausted — extract the final error message from the Cause.
141
- // Use Cause.pretty so defects (uncaught throws) are surfaced too, not
142
- // just typed failures from Cause.failures.
143
- let failMsg = exit->Exit.match(
144
- ~onFailure=cause => cause->Cause.pretty,
145
- ~onSuccess=_ => "storage error", // unreachable: we are in the isFailure branch
146
- )
147
- // Propagate conflict errors without wrapping so callers can detect them
148
- if isConflict(failMsg) {
149
- Error("conflict")
150
- } else {
151
- Error(`EventLog: Error: Couldn't append for ${Spec.name}(${idStr}): ${failMsg}`)
152
- }
146
+ ->Effect.map(_ => Ok())
147
+ ->Effect.catchAll(e => Effect.succeed(Error(e)))
148
+ switch await storageEffect->Effect.runPromise {
149
+ | Ok() =>
150
+ (await publishToEventTopic(id, events', eventsJson))->Result.mapError(msg => EventLog.StorageFailure(
151
+ msg,
152
+ ))
153
+ | Error(EventLog.Conflict) => Error(EventLog.Conflict)
154
+ | Error(StorageFailure(msg)) =>
155
+ Error(EventLog.StorageFailure(`EventLog: Error: Couldn't append for ${Spec.name}(${idStr}): ${msg}`))
153
156
  }
154
157
  }
155
158
 
@@ -1,12 +1,10 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
- import * as Exit from "@reventlessdev/rescript-effect/src/Exit.res.mjs";
4
3
  import * as Effect from "@reventlessdev/rescript-effect/src/Effect.res.mjs";
5
4
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
6
- import * as Exit$1 from "effect/Exit";
7
5
  import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
8
- import * as Cause from "effect/Cause";
9
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
10
8
  import * as Effect$1 from "effect/Effect";
11
9
  import * as Stream from "effect/Stream";
12
10
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
@@ -18,7 +16,11 @@ import * as Util_Error$ReventlessCore from "../../util/Util_Error.res.mjs";
18
16
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
19
17
  import * as EventPublish_Callback$ReventlessCore from "./EventPublish_Callback.res.mjs";
20
18
 
21
- function isTransient(msg) {
19
+ function isTransient(e) {
20
+ if (typeof e !== "object") {
21
+ return false;
22
+ }
23
+ let msg = e._0;
22
24
  if (msg.includes("ThrottlingException") || msg.includes("ProvisionedThroughputExceededException") || msg.includes("ServiceUnavailable") || msg.includes("RequestLimitExceeded")) {
23
25
  return true;
24
26
  } else {
@@ -111,27 +113,42 @@ function Make(Spec) {
111
113
  let append = async (sequenceNr, id, events$p) => {
112
114
  let eventsJson = encodeEvents$p(events$p, id, sequenceNr);
113
115
  let idStr = Spec.Id.toString(id);
114
- let storageEffect = Effect$1.retry(Effect$1.flatMap(Effect.tryPromise(err => Util_Error$ReventlessCore.messageFromUnknown(err, "storage error"), () => Ops.storage.append(sequenceNr, idStr, eventsJson)), result => {
116
+ let storageEffect = Effect$1.catchAll(Effect$1.map(Effect$1.retry(Effect$1.flatMap(Effect.tryPromise(err => ({
117
+ TAG: "StorageFailure",
118
+ _0: Util_Error$ReventlessCore.messageFromUnknown(err, "storage error")
119
+ }), () => Ops.storage.append(sequenceNr, idStr, eventsJson)), result => {
115
120
  if (result.TAG === "Ok") {
116
121
  return Effect$1.succeed();
117
122
  } else {
118
123
  return Effect$1.fail(result._0);
119
124
  }
120
- }), storageRetrySchedule);
121
- let exit = await Effect$1.runPromiseExit(storageEffect);
122
- if (Exit$1.isSuccess(exit)) {
123
- return await publishToEventTopic(id, events$p, eventsJson);
125
+ }), storageRetrySchedule), () => ({
126
+ TAG: "Ok",
127
+ _0: undefined
128
+ })), e => Effect$1.succeed({
129
+ TAG: "Error",
130
+ _0: e
131
+ }));
132
+ let match = await Effect$1.runPromise(storageEffect);
133
+ if (match.TAG === "Ok") {
134
+ return Stdlib_Result.mapError(await publishToEventTopic(id, events$p, eventsJson), msg => ({
135
+ TAG: "StorageFailure",
136
+ _0: msg
137
+ }));
124
138
  }
125
- let failMsg = Exit.match(exit, cause => Cause.pretty(cause), () => "storage error");
126
- if (failMsg.includes("conflict")) {
139
+ let msg = match._0;
140
+ if (typeof msg !== "object") {
127
141
  return {
128
142
  TAG: "Error",
129
- _0: "conflict"
143
+ _0: "Conflict"
130
144
  };
131
145
  } else {
132
146
  return {
133
147
  TAG: "Error",
134
- _0: `EventLog: Error: Couldn't append for ` + Spec.name + `(` + idStr + `): ` + failMsg
148
+ _0: {
149
+ TAG: "StorageFailure",
150
+ _0: `EventLog: Error: Couldn't append for ` + Spec.name + `(` + idStr + `): ` + msg._0
151
+ }
135
152
  };
136
153
  }
137
154
  };
@@ -38,7 +38,19 @@ let rec describeSchema = (schema: S.t<unknown>): string =>
38
38
  | _ => "unknown"
39
39
  }
40
40
  "array<" ++ itemDesc ++ ">"
41
- | Object(_) => "object"
41
+ | Object({properties}) =>
42
+ // Recurse into nested records. Rendering every object as the literal
43
+ // "object" made the structural hash blind to nested field changes — a
44
+ // nested schema edit produced an identical SHA256, so drift detection
45
+ // silently missed it. Emit a sorted `{name:type,…}` description instead.
46
+ let inner =
47
+ properties
48
+ ->Dict.toArray
49
+ ->Array.filter(((name, _)) => name != "TAG")
50
+ ->Array.toSorted(((a, _), (b, _)) => String.compare(a, b))
51
+ ->Array.map(((name, s)) => name ++ ":" ++ describeSchema(s))
52
+ ->Array.join(",")
53
+ "{" ++ inner ++ "}"
42
54
  | _ => "unknown"
43
55
  }
44
56
 
@@ -23,7 +23,8 @@ function describeSchema(schema) {
23
23
  itemDesc = additionalItems === "strip" || additionalItems === "strict" ? "unknown" : describeSchema(additionalItems);
24
24
  return "array<" + itemDesc + ">";
25
25
  case "object" :
26
- return "object";
26
+ let inner = Object.entries(schema.properties).filter(param => param[0] !== "TAG").toSorted((param, param$1) => Primitive_string.compare(param[0], param$1[0])).map(param => param[0] + ":" + describeSchema(param[1])).join(",");
27
+ return "{" + inner + "}";
27
28
  case "union" :
28
29
  let nonNull = schema.anyOf.filter(v => {
29
30
  switch (v.type) {
@@ -34,9 +35,9 @@ function describeSchema(schema) {
34
35
  return true;
35
36
  }
36
37
  });
37
- let inner = nonNull[0];
38
- if (inner !== undefined) {
39
- return "option<" + describeSchema(inner) + ">";
38
+ let inner$1 = nonNull[0];
39
+ if (inner$1 !== undefined) {
40
+ return "option<" + describeSchema(inner$1) + ">";
40
41
  } else {
41
42
  return "unknown";
42
43
  }
@@ -63,7 +63,12 @@ module Make = (Spec: Spec, Config: Config) => {
63
63
  ~comp="CommandPublisher",
64
64
  `send: bufferSize: ${bufferSizeStr}, chunk: ${chunkCountStr}, size: ${sizeStr}`,
65
65
  )->Effect.runSync
66
- let commandsToSend = buffer->Array.toSpliced(~start=0, ~remove=size, ~insert=[])
66
+ // Take the first `size` commands and remove them from the buffer.
67
+ // (`toSpliced` is non-mutating and returns the *remainder*, so the buffer
68
+ // never drained: send() recursed on the same buffer and published the
69
+ // wrong slice. `slice` captures the chunk; `splice` drains it in place.)
70
+ let commandsToSend = buffer->Array.slice(~start=0, ~end=size)
71
+ buffer->Array.splice(~start=0, ~remove=size, ~insert=[])
67
72
  let promise = Config.publishCommands(Spec.name, commandsToSend->toJsons)
68
73
  running := Some(promise)
69
74
  switch await promise {
@@ -82,7 +87,12 @@ module Make = (Spec: Spec, Config: Config) => {
82
87
  | SendAllInOneChunk =>
83
88
  let size = buffer->Array.length
84
89
  if size > 0 {
85
- let commandsToSend = buffer->Array.toSpliced(~start=0, ~remove=size, ~insert=[])
90
+ // Take the first `size` commands and remove them from the buffer.
91
+ // (`toSpliced` is non-mutating and returns the *remainder*, so the buffer
92
+ // never drained: send() recursed on the same buffer and published the
93
+ // wrong slice. `slice` captures the chunk; `splice` drains it in place.)
94
+ let commandsToSend = buffer->Array.slice(~start=0, ~end=size)
95
+ buffer->Array.splice(~start=0, ~remove=size, ~insert=[])
86
96
  let promise = Config.publishCommands(Spec.name, commandsToSend->toJsons)
87
97
  running := Some(promise)
88
98
  switch await promise {
@@ -115,19 +125,19 @@ module Make = (Spec: Spec, Config: Config) => {
115
125
 
116
126
  let clear = () => {
117
127
  EffectLogger.logDebug(~comp="CommandPublisher", "clear")->Effect.runSync
118
- let _ = buffer->Array.removeInPlace(0)
128
+ // Truncate the whole buffer. `removeInPlace(0)` is `splice(0, 1)` — it drops
129
+ // only the first element, so the rest leaked out on a later publish.
130
+ buffer->Array.splice(~start=0, ~remove=buffer->Array.length, ~insert=[])
119
131
  flush := false
120
132
  }
121
133
 
122
134
  let flush = async () => {
123
135
  EffectLogger.logDebug(~comp="CommandPublisher", "flush")->Effect.runSync
124
136
  flush := true
125
- switch running.contents {
126
- | None => await send()
127
- | _ =>
128
- while running.contents->Option.isNone {
129
- await finishRunning()
130
- }
131
- }
137
+ // `send` already awaits any in-flight publish (finishRunning) before draining
138
+ // the buffer, and flush:=true forces a partial final chunk. The previous
139
+ // in-flight arm looped on `running.contents->Option.isNone` — dead, since this
140
+ // path only ran when running was Some — so buffered commands were never sent.
141
+ await send()
132
142
  }
133
143
  }
@@ -54,7 +54,8 @@ function Make(Spec) {
54
54
  if (size <= 0) {
55
55
  return;
56
56
  }
57
- let commandsToSend = buffer.toSpliced(0, size);
57
+ let commandsToSend = buffer.slice(0, size);
58
+ buffer.splice(0, size);
58
59
  let promise = Config.publishCommands(Spec.name, toJsons(commandsToSend));
59
60
  running.contents = promise;
60
61
  let exit = 0;
@@ -88,7 +89,8 @@ function Make(Spec) {
88
89
  let bufferSizeStr = buffer.length.toString();
89
90
  let chunkCountStr = chunkCount.contents.toString();
90
91
  Effect.runSync(EffectLogger$ReventlessCore.logDebug("CommandPublisher", undefined, `send: bufferSize: ` + bufferSizeStr + `, chunk: ` + chunkCountStr + `, size: ` + sizeStr));
91
- let commandsToSend$1 = buffer.toSpliced(0, size$1);
92
+ let commandsToSend$1 = buffer.slice(0, size$1);
93
+ buffer.splice(0, size$1);
92
94
  let promise$1 = Config.publishCommands(Spec.name, toJsons(commandsToSend$1));
93
95
  running.contents = promise$1;
94
96
  let exit$1 = 0;
@@ -127,19 +129,13 @@ function Make(Spec) {
127
129
  };
128
130
  let clear = () => {
129
131
  Effect.runSync(EffectLogger$ReventlessCore.logDebug("CommandPublisher", undefined, "clear"));
130
- buffer.splice(0, 1);
132
+ buffer.splice(0, buffer.length);
131
133
  flush.contents = false;
132
134
  };
133
135
  let flush$1 = async () => {
134
136
  Effect.runSync(EffectLogger$ReventlessCore.logDebug("CommandPublisher", undefined, "flush"));
135
137
  flush.contents = true;
136
- let match = running.contents;
137
- if (match === undefined) {
138
- return await send();
139
- }
140
- while (Stdlib_Option.isNone(running.contents)) {
141
- await finishRunning();
142
- };
138
+ return await send();
143
139
  };
144
140
  return {
145
141
  buffer: buffer,
@@ -1,2 +1,2 @@
1
1
  let containsByPredicate: (array<'a>, 'a => bool) => bool = (arr, predicate) =>
2
- arr->Array.find(predicate)->Option.isNone
2
+ arr->Array.find(predicate)->Option.isSome
@@ -3,7 +3,7 @@
3
3
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
4
 
5
5
  function containsByPredicate(arr, predicate) {
6
- return Stdlib_Option.isNone(arr.find(predicate));
6
+ return Stdlib_Option.isSome(arr.find(predicate));
7
7
  }
8
8
 
9
9
  export {
@@ -41,7 +41,7 @@ module TestBehavior = {
41
41
  }
42
42
 
43
43
  type mockEL = {
44
- appendFn: (int, string, array<Message.event'<string, AggSpec.event>>) => promise<result<unit, string>>,
44
+ appendFn: (int, string, array<Message.event'<string, AggSpec.event>>) => promise<result<unit, EventLog.appendError>>,
45
45
  replayFn: string => promise<array<AggSpec.event>>,
46
46
  replayStreamFn: string => Stream.t<AggSpec.event, string, unit>,
47
47
  getAll: unit => array<Message.event'<string, AggSpec.event>>,
@@ -61,7 +61,7 @@ let makeMockEL = (): mockEL => {
61
61
  appendCount := appendCount.contents + 1
62
62
  if failConflictRef.contents > 0 {
63
63
  failConflictRef := failConflictRef.contents - 1
64
- Error("conflict")
64
+ Error(EventLog.Conflict)
65
65
  } else {
66
66
  storedRef := storedRef.contents->Array.concat(newEvents)
67
67
  Ok(())
@@ -114,7 +114,7 @@ module TestOps = {
114
114
  type event = AggSpec.event
115
115
  }
116
116
  type operations = {
117
- append: (int, string, array<Message.event'<string, AggSpec.event>>) => promise<result<unit, string>>,
117
+ append: (int, string, array<Message.event'<string, AggSpec.event>>) => promise<result<unit, EventLog.appendError>>,
118
118
  replay: string => promise<array<AggSpec.event>>,
119
119
  replayStream: string => Stream.t<AggSpec.event, string, unit>,
120
120
  appendStream: (int, string, Stream.t<AggSpec.event, string, unit>) => Effect.t<unit, string, unit>,
@@ -87,7 +87,7 @@ function makeMockEL() {
87
87
  failConflictRef.contents = failConflictRef.contents - 1 | 0;
88
88
  return {
89
89
  TAG: "Error",
90
- _0: "conflict"
90
+ _0: "Conflict"
91
91
  };
92
92
  } else {
93
93
  storedRef.contents = storedRef.contents.concat(newEvents);
@@ -60,7 +60,7 @@ module TestBehavior = {
60
60
  // ─────────────────────────────────────────────────────────────
61
61
 
62
62
  type mockEL = {
63
- appendFn: (int, string, array<Message.event'<string, AggSpec.event>>) => promise<result<unit, string>>,
63
+ appendFn: (int, string, array<Message.event'<string, AggSpec.event>>) => promise<result<unit, EventLog.appendError>>,
64
64
  replayFn: string => promise<array<AggSpec.event>>,
65
65
  replayStreamFn: string => Stream.t<AggSpec.event, string, unit>,
66
66
  getAll: unit => array<Message.event'<string, AggSpec.event>>,
@@ -75,7 +75,7 @@ let makeMockEL = (): mockEL => {
75
75
  let appendFn = async (_seqNr, _id, newEvents) =>
76
76
  if failRef.contents {
77
77
  failRef := false
78
- Error("append failed")
78
+ Error(EventLog.StorageFailure("append failed"))
79
79
  } else {
80
80
  storedRef := storedRef.contents->Array.concat(newEvents)
81
81
  Ok(())
@@ -129,7 +129,7 @@ module TestOps = {
129
129
  int,
130
130
  string,
131
131
  array<Message.event'<string, AggSpec.event>>,
132
- ) => promise<result<unit, string>>,
132
+ ) => promise<result<unit, EventLog.appendError>>,
133
133
  replay: string => promise<array<AggSpec.event>>,
134
134
  replayStream: string => Stream.t<AggSpec.event, string, unit>,
135
135
  appendStream: (int, string, Stream.t<AggSpec.event, string, unit>) => Effect.t<unit, string, unit>,
@@ -129,7 +129,10 @@ function makeMockEL() {
129
129
  failRef.contents = false;
130
130
  return {
131
131
  TAG: "Error",
132
- _0: "append failed"
132
+ _0: {
133
+ TAG: "StorageFailure",
134
+ _0: "append failed"
135
+ }
133
136
  };
134
137
  } else {
135
138
  storedRef.contents = storedRef.contents.concat(newEvents);
@@ -28,10 +28,10 @@ let mockStorage: EventLog_Adapter.operations = {
28
28
  appendCallCount := appendCallCount.contents + 1
29
29
  if failNextAppend.contents {
30
30
  failNextAppend := false
31
- Error("mock storage failure")
31
+ Error(EventLog.StorageFailure("mock storage failure"))
32
32
  } else if failNextAppendsWithTransient.contents > 0 {
33
33
  failNextAppendsWithTransient := failNextAppendsWithTransient.contents - 1
34
- Error("ThrottlingException: Rate exceeded")
34
+ Error(EventLog.StorageFailure("ThrottlingException: Rate exceeded"))
35
35
  } else {
36
36
  let existing = storedEvents.contents->Dict.get(id)->Option.getOr([])
37
37
  storedEvents.contents->Dict.set(id, existing->Array.concat(jsons))
@@ -47,14 +47,20 @@ async function mockStorage_append(_seqNr, id, jsons) {
47
47
  failNextAppend.contents = false;
48
48
  return {
49
49
  TAG: "Error",
50
- _0: "mock storage failure"
50
+ _0: {
51
+ TAG: "StorageFailure",
52
+ _0: "mock storage failure"
53
+ }
51
54
  };
52
55
  }
53
56
  if (failNextAppendsWithTransient.contents > 0) {
54
57
  failNextAppendsWithTransient.contents = failNextAppendsWithTransient.contents - 1 | 0;
55
58
  return {
56
59
  TAG: "Error",
57
- _0: "ThrottlingException: Rate exceeded"
60
+ _0: {
61
+ TAG: "StorageFailure",
62
+ _0: "ThrottlingException: Rate exceeded"
63
+ }
58
64
  };
59
65
  }
60
66
  let existing = Stdlib_Option.getOr(storedEvents.contents[id], []);
@@ -16,36 +16,37 @@ let _ = beforeEach(() => reset())
16
16
 
17
17
  describe("EventLog_Operations — retry:", () => {
18
18
  describe("isTransient predicate:", () => {
19
+ let transient = msg => EventLog_Operations.isTransient(EventLog.StorageFailure(msg))
19
20
  testPromise("ThrottlingException is transient", async () => {
20
- expect(EventLog_Operations.isTransient("ThrottlingException: Rate exceeded"))->toBe(true)
21
+ expect(transient("ThrottlingException: Rate exceeded"))->toBe(true)
21
22
  })
22
23
 
23
24
  testPromise("ProvisionedThroughputExceededException is transient", async () => {
24
- expect(
25
- EventLog_Operations.isTransient("ProvisionedThroughputExceededException"),
26
- )->toBe(true)
25
+ expect(transient("ProvisionedThroughputExceededException"))->toBe(true)
27
26
  })
28
27
 
29
28
  testPromise("ServiceUnavailable is transient", async () => {
30
- expect(EventLog_Operations.isTransient("ServiceUnavailable"))->toBe(true)
29
+ expect(transient("ServiceUnavailable"))->toBe(true)
31
30
  })
32
31
 
33
32
  testPromise("RequestLimitExceeded is transient", async () => {
34
- expect(EventLog_Operations.isTransient("RequestLimitExceeded"))->toBe(true)
33
+ expect(transient("RequestLimitExceeded"))->toBe(true)
35
34
  })
36
35
 
37
36
  testPromise("InternalServerError is transient", async () => {
38
- expect(EventLog_Operations.isTransient("InternalServerError: something went wrong"))->toBe(
39
- true,
40
- )
37
+ expect(transient("InternalServerError: something went wrong"))->toBe(true)
41
38
  })
42
39
 
43
40
  testPromise("ValidationException is not transient", async () => {
44
- expect(EventLog_Operations.isTransient("ValidationException: invalid field"))->toBe(false)
41
+ expect(transient("ValidationException: invalid field"))->toBe(false)
45
42
  })
46
43
 
47
44
  testPromise("generic mock storage failure is not transient", async () => {
48
- expect(EventLog_Operations.isTransient("mock storage failure"))->toBe(false)
45
+ expect(transient("mock storage failure"))->toBe(false)
46
+ })
47
+
48
+ testPromise("a Conflict is never transient (retried a layer up, not here)", async () => {
49
+ expect(EventLog_Operations.isTransient(EventLog.Conflict))->toBe(false)
49
50
  })
50
51
  })
51
52
 
@@ -74,25 +74,49 @@ globalThis.beforeEach(() => EventLogFixtures$ReventlessCore.reset());
74
74
  globalThis.describe("EventLog_Operations — retry:", () => {
75
75
  globalThis.describe("isTransient predicate:", () => {
76
76
  globalThis.test("ThrottlingException is transient", async () => {
77
- globalThis.expect(EventLog_Operations$ReventlessCore.isTransient("ThrottlingException: Rate exceeded")).toBe(true);
77
+ globalThis.expect(EventLog_Operations$ReventlessCore.isTransient({
78
+ TAG: "StorageFailure",
79
+ _0: "ThrottlingException: Rate exceeded"
80
+ })).toBe(true);
78
81
  });
79
82
  globalThis.test("ProvisionedThroughputExceededException is transient", async () => {
80
- globalThis.expect(EventLog_Operations$ReventlessCore.isTransient("ProvisionedThroughputExceededException")).toBe(true);
83
+ globalThis.expect(EventLog_Operations$ReventlessCore.isTransient({
84
+ TAG: "StorageFailure",
85
+ _0: "ProvisionedThroughputExceededException"
86
+ })).toBe(true);
81
87
  });
82
88
  globalThis.test("ServiceUnavailable is transient", async () => {
83
- globalThis.expect(EventLog_Operations$ReventlessCore.isTransient("ServiceUnavailable")).toBe(true);
89
+ globalThis.expect(EventLog_Operations$ReventlessCore.isTransient({
90
+ TAG: "StorageFailure",
91
+ _0: "ServiceUnavailable"
92
+ })).toBe(true);
84
93
  });
85
94
  globalThis.test("RequestLimitExceeded is transient", async () => {
86
- globalThis.expect(EventLog_Operations$ReventlessCore.isTransient("RequestLimitExceeded")).toBe(true);
95
+ globalThis.expect(EventLog_Operations$ReventlessCore.isTransient({
96
+ TAG: "StorageFailure",
97
+ _0: "RequestLimitExceeded"
98
+ })).toBe(true);
87
99
  });
88
100
  globalThis.test("InternalServerError is transient", async () => {
89
- globalThis.expect(EventLog_Operations$ReventlessCore.isTransient("InternalServerError: something went wrong")).toBe(true);
101
+ globalThis.expect(EventLog_Operations$ReventlessCore.isTransient({
102
+ TAG: "StorageFailure",
103
+ _0: "InternalServerError: something went wrong"
104
+ })).toBe(true);
90
105
  });
91
106
  globalThis.test("ValidationException is not transient", async () => {
92
- globalThis.expect(EventLog_Operations$ReventlessCore.isTransient("ValidationException: invalid field")).toBe(false);
107
+ globalThis.expect(EventLog_Operations$ReventlessCore.isTransient({
108
+ TAG: "StorageFailure",
109
+ _0: "ValidationException: invalid field"
110
+ })).toBe(false);
93
111
  });
94
112
  globalThis.test("generic mock storage failure is not transient", async () => {
95
- globalThis.expect(EventLog_Operations$ReventlessCore.isTransient("mock storage failure")).toBe(false);
113
+ globalThis.expect(EventLog_Operations$ReventlessCore.isTransient({
114
+ TAG: "StorageFailure",
115
+ _0: "mock storage failure"
116
+ })).toBe(false);
117
+ });
118
+ globalThis.test("a Conflict is never transient (retried a layer up, not here)", async () => {
119
+ globalThis.expect(EventLog_Operations$ReventlessCore.isTransient("Conflict")).toBe(false);
96
120
  });
97
121
  });
98
122
  globalThis.describe("permanent storage failure:", () => {
@@ -0,0 +1,38 @@
1
+ // Regression test for Projection.optimizeActions (plan A7 / D2).
2
+ //
3
+ // optimizeActions merges consecutive per-id actions. A non-merging pair (Delete
4
+ // then Update) grows the accumulator to length 2; the next merge (Update+Set)
5
+ // must fold only the last action and keep everything before it exactly once.
6
+ // Before the `~end=count-1` fix, `previousActions` still contained the
7
+ // just-merged action, so the earlier action was duplicated — applied twice.
8
+
9
+ open JestGlobals
10
+ open Reventless.Projection
11
+
12
+ describe("Projection.optimizeActions", () => {
13
+ testSync("does not duplicate the pre-merge action (off-by-one regression)", () => {
14
+ let optimized = Projection.optimizeActions([Delete("a"), Update("a", x => x + 1), Set("a", 99)])
15
+ // Was 3 (with a duplicated Update("a")) before the fix.
16
+ expect(optimized->Array.length)->toBe(2)
17
+ let sets =
18
+ optimized->Array.filterMap(a =>
19
+ switch a {
20
+ | Set(id, s) => Some((id, s))
21
+ | _ => None
22
+ }
23
+ )
24
+ expect(sets)->toEqual([("a", 99)])
25
+ })
26
+
27
+ testSync("merges a same-id Create+Update chain into a single Create", () => {
28
+ let optimized = Projection.optimizeActions([Create("a", 0), Update("a", x => x + 1), Update("a", x => x + 10)])
29
+ let creates =
30
+ optimized->Array.filterMap(a =>
31
+ switch a {
32
+ | Create(id, s) => Some((id, s))
33
+ | _ => None
34
+ }
35
+ )
36
+ expect(creates)->toEqual([("a", 11)])
37
+ })
38
+ })
@@ -0,0 +1,75 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
+ import * as Projection$ReventlessCore from "../../src/Projection.res.mjs";
5
+
6
+ globalThis.describe("Projection.optimizeActions", () => {
7
+ globalThis.test("does not duplicate the pre-merge action (off-by-one regression)", () => {
8
+ let optimized = Projection$ReventlessCore.optimizeActions([
9
+ {
10
+ TAG: "Delete",
11
+ _0: "a"
12
+ },
13
+ {
14
+ TAG: "Update",
15
+ _0: "a",
16
+ _1: x => x + 1 | 0
17
+ },
18
+ {
19
+ TAG: "Set",
20
+ _0: "a",
21
+ _1: 99
22
+ }
23
+ ]);
24
+ globalThis.expect(optimized.length).toBe(2);
25
+ let sets = Stdlib_Array.filterMap(optimized, a => {
26
+ if (typeof a !== "object" || a.TAG !== "Set") {
27
+ return;
28
+ } else {
29
+ return [
30
+ a._0,
31
+ a._1
32
+ ];
33
+ }
34
+ });
35
+ globalThis.expect(sets).toEqual([[
36
+ "a",
37
+ 99
38
+ ]]);
39
+ });
40
+ globalThis.test("merges a same-id Create+Update chain into a single Create", () => {
41
+ let optimized = Projection$ReventlessCore.optimizeActions([
42
+ {
43
+ TAG: "Create",
44
+ _0: "a",
45
+ _1: 0
46
+ },
47
+ {
48
+ TAG: "Update",
49
+ _0: "a",
50
+ _1: x => x + 1 | 0
51
+ },
52
+ {
53
+ TAG: "Update",
54
+ _0: "a",
55
+ _1: x => x + 10 | 0
56
+ }
57
+ ]);
58
+ let creates = Stdlib_Array.filterMap(optimized, a => {
59
+ if (typeof a !== "object" || a.TAG !== "Create") {
60
+ return;
61
+ } else {
62
+ return [
63
+ a._0,
64
+ a._1
65
+ ];
66
+ }
67
+ });
68
+ globalThis.expect(creates).toEqual([[
69
+ "a",
70
+ 11
71
+ ]]);
72
+ });
73
+ });
74
+
75
+ /* Not a pure module */
@@ -0,0 +1,56 @@
1
+ // Regression tests for CommandPublisher (plan A7).
2
+ //
3
+ // Two bugs made the buffered publisher silently lose commands:
4
+ // - send() used the non-mutating `toSpliced`, so the buffer never drained and
5
+ // the wrong slice was published.
6
+ // - flush()'s in-flight arm looped on a dead condition and never sent.
7
+ // - clear() removed only the first buffered command instead of truncating.
8
+ //
9
+ // These tests drive the functor with a capturing publishCommands and assert the
10
+ // buffer flushes in order and clear() empties it.
11
+
12
+ open JestGlobals
13
+
14
+ S.enableJson()
15
+
16
+ let published: ref<array<Reventless.Message.commandJson>> = ref([])
17
+
18
+ module TestSpec = {
19
+ @schema let name = "TestCmd"
20
+ @schema type command = {value: int}
21
+ }
22
+
23
+ module TestConfig = {
24
+ let user = "tester"
25
+ let publishCommands: Task.publishCommands = (_name, cmds) => {
26
+ published := published.contents->Array.concat(cmds)
27
+ Promise.resolve()
28
+ }
29
+ let mode = CommandPublisher.SendAllInOneChunk
30
+ }
31
+
32
+ module Pub = CommandPublisher.Make(TestSpec, TestConfig)
33
+
34
+ describe("CommandPublisher", () => {
35
+ testPromise("flush sends all buffered commands in order and drains the buffer", async () => {
36
+ published := []
37
+ Pub.publish("id1", {value: 1})
38
+ Pub.publish("id2", {value: 2})
39
+ await Pub.flush()
40
+ let ids = published.contents->Array.map((c: Reventless.Message.commandJson) => c.id)
41
+ expect(ids)->toEqual(["id1", "id2"])
42
+ // Buffer drained: a second flush sends nothing more.
43
+ published := []
44
+ await Pub.flush()
45
+ expect(published.contents->Array.length)->toBe(0)
46
+ })
47
+
48
+ testPromise("clear truncates the whole buffer so nothing is later published", async () => {
49
+ published := []
50
+ Pub.publish("id3", {value: 3})
51
+ Pub.publish("id4", {value: 4})
52
+ Pub.clear()
53
+ await Pub.flush()
54
+ expect(published.contents->Array.length)->toBe(0)
55
+ })
56
+ })
@@ -0,0 +1,78 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as CommandPublisher$ReventlessCore from "../../src/util/CommandPublisher.res.mjs";
5
+
6
+ S.enableJson();
7
+
8
+ let published = {
9
+ contents: []
10
+ };
11
+
12
+ let commandSchema = S.schema(s => ({
13
+ value: s.m(S.int)
14
+ }));
15
+
16
+ function commandAuthorization(param) {
17
+ return "AllowAuthenticated";
18
+ }
19
+
20
+ let TestSpec = {
21
+ name: "TestCmd",
22
+ commandSchema: commandSchema,
23
+ commandAuthorization: commandAuthorization
24
+ };
25
+
26
+ function publishCommands(_name, cmds) {
27
+ published.contents = published.contents.concat(cmds);
28
+ return Promise.resolve();
29
+ }
30
+
31
+ let TestConfig = {
32
+ user: "tester",
33
+ publishCommands: publishCommands,
34
+ mode: "SendAllInOneChunk"
35
+ };
36
+
37
+ let Pub = CommandPublisher$ReventlessCore.Make(TestSpec)(TestConfig);
38
+
39
+ globalThis.describe("CommandPublisher", () => {
40
+ globalThis.test("flush sends all buffered commands in order and drains the buffer", async () => {
41
+ published.contents = [];
42
+ Pub.publish("id1", {
43
+ value: 1
44
+ });
45
+ Pub.publish("id2", {
46
+ value: 2
47
+ });
48
+ await Pub.flush();
49
+ let ids = published.contents.map(c => c.id);
50
+ globalThis.expect(ids).toEqual([
51
+ "id1",
52
+ "id2"
53
+ ]);
54
+ published.contents = [];
55
+ await Pub.flush();
56
+ globalThis.expect(published.contents.length).toBe(0);
57
+ });
58
+ globalThis.test("clear truncates the whole buffer so nothing is later published", async () => {
59
+ published.contents = [];
60
+ Pub.publish("id3", {
61
+ value: 3
62
+ });
63
+ Pub.publish("id4", {
64
+ value: 4
65
+ });
66
+ Pub.clear();
67
+ await Pub.flush();
68
+ globalThis.expect(published.contents.length).toBe(0);
69
+ });
70
+ });
71
+
72
+ export {
73
+ published,
74
+ TestSpec,
75
+ TestConfig,
76
+ Pub,
77
+ }
78
+ /* Not a pure module */