@reventlessdev/reventless-local 3.0.0-alpha.115 → 3.0.0-alpha.116

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +6 -6
  3. package/src/Platform.res +91 -17
  4. package/src/Platform.res.mjs +89 -30
  5. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +102 -17
  6. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +105 -22
  7. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +113 -39
  8. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +87 -23
  9. package/src/adapter/EventCollector/LocalEventCollectorChannel.res +33 -0
  10. package/src/adapter/EventCollector/LocalEventCollectorChannel.res.mjs +44 -0
  11. package/src/adapter/EventLog/EventLogStorage_InMemory.res +23 -2
  12. package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +20 -3
  13. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +132 -24
  14. package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +119 -37
  15. package/src/adapter/LocalBus.res +62 -0
  16. package/src/adapter/LocalBus.res.mjs +120 -0
  17. package/src/adapter/ProjectionCheckpoint.res +350 -0
  18. package/src/adapter/ProjectionCheckpoint.res.mjs +327 -0
  19. package/src/adapter/ProjectionPending.res +60 -0
  20. package/src/adapter/ProjectionPending.res.mjs +64 -0
  21. package/src/adapter/QueryDb/QueryDbListQuery.res +261 -0
  22. package/src/adapter/QueryDb/QueryDbListQuery.res.mjs +212 -0
  23. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +54 -286
  24. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +28 -190
  25. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +32 -6
  26. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +30 -14
  27. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +163 -11
  28. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +101 -3
  29. package/src/adapter/SqliteDriver.res +11 -1
  30. package/src/adapter/SqliteDriver.res.mjs +5 -1
  31. package/src/components/ReadModel_Builder.res +3 -1
  32. package/src/components/ReadModel_Builder.res.mjs +13 -4
  33. package/src/components/StateViewSlice_Builder.res +4 -1
  34. package/src/components/StateViewSlice_Builder.res.mjs +9 -3
  35. package/src/test/Mocks/MockEventLogStorage.res +19 -2
  36. package/src/test/Mocks/MockEventLogStorage.res.mjs +21 -2
  37. package/tests/PluginEventDecodeTest.res +51 -0
  38. package/tests/PluginEventDecodeTest.res.mjs +63 -0
  39. package/tests/adapter/DcbEventLogStorageSqliteTest.res +73 -0
  40. package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +86 -0
  41. package/tests/adapter/DcbEventLogStorageTest.res +46 -0
  42. package/tests/adapter/DcbEventLogStorageTest.res.mjs +63 -0
  43. package/tests/adapter/EventLogSnapshotParityTest.res +81 -0
  44. package/tests/adapter/EventLogSnapshotParityTest.res.mjs +141 -0
  45. package/tests/adapter/EventLogStorageSqliteTest.res +128 -0
  46. package/tests/adapter/EventLogStorageSqliteTest.res.mjs +189 -0
  47. package/tests/adapter/ProjectionCheckpointTest.res +422 -0
  48. package/tests/adapter/ProjectionCheckpointTest.res.mjs +401 -0
  49. package/tests/adapter/QueryDbGsiTtlTest.res +78 -0
  50. package/tests/adapter/QueryDbGsiTtlTest.res.mjs +77 -0
  51. package/tests/adapter/QueryDbListPushdownParityTest.res +197 -0
  52. package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +292 -0
  53. package/tests/adapter/QueryDbListResolverTest.res +76 -0
  54. package/tests/adapter/QueryDbListResolverTest.res.mjs +84 -0
  55. package/tests/components/aggregate/AggregateFixtures.res +4 -0
  56. package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -0
  57. package/tests/components/eventlog/EventLogAppendStreamTest.res.mjs +4 -4
  58. package/tests/components/eventlog/EventLogStreamTest.res.mjs +8 -8
  59. package/tests/plugin/PluginBehavior_GWT.res.mjs +1 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
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.116 (2026-07-03)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **local:** decode plugin events from the bus envelope in subscribeToPluginEvents ([0f24dc1](https://github.com/ReventlessDev/reventless-core/commit/0f24dc129bf2631b94c300d7d5f6ceba3a9bb9f7))
11
+ ### Features
12
+
13
+ * **core,local,aws:** EventLog snapshot storage surface, all backends (aggregate-snapshotting plan, steps 3+4) ([b6e50e2](https://github.com/ReventlessDev/reventless-core/commit/b6e50e2e5fe50b7372e54811f883fa91f6758dd1))
14
+ * **local:** DCB-axis projection checkpoints + startup catch-up (plan B5) ([8f9b11d](https://github.com/ReventlessDev/reventless-core/commit/8f9b11d24627bb6cc105d37af4974745444b80ba))
15
+ * **local:** projection checkpoints + startup catch-up under SQLite (plan B5) ([d28f30e](https://github.com/ReventlessDev/reventless-core/commit/d28f30e25d43cbf378b3aab091b1979dbb449a4f))
16
+ ### Performance Improvements
17
+
18
+ * **local:** DCB in-memory posting lists for tag/type queries (plan B4) ([7859fed](https://github.com/ReventlessDev/reventless-core/commit/7859fede8ad6ab44f657d4d9dd44fba08e85e3aa))
19
+ * **local:** DCB SQLite batched tag reads + EXISTS conflict check (plan B4) ([c9d647b](https://github.com/ReventlessDev/reventless-core/commit/c9d647b73adba4e054edbeba83dfb9c8111e4dfb))
20
+ * **local:** EventLog SQLite MAX(seq_nr)+1 + batched appendStream (plan B4) ([7b24fb1](https://github.com/ReventlessDev/reventless-core/commit/7b24fb12b78b79854d3c345a74f1eab68a28e745))
21
+ * **local:** SQLite connection-list push-down with parity harness (plan B4 complete) ([d78d428](https://github.com/ReventlessDev/reventless-core/commit/d78d4282c923c64cbde3432cd906845705a1e97b))
22
+ * **local:** SQLite pragmas, lazy in-memory scan, indexed lookup push-down (plan B4) ([b0ff78a](https://github.com/ReventlessDev/reventless-core/commit/b0ff78a5c02030eef9fc5bb1ee8b92c65114fa45))
23
+
24
+
6
25
  # 3.0.0-alpha.115 (2026-07-02)
7
26
 
8
27
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.115",
3
+ "version": "3.0.0-alpha.116",
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
  "jest": {
@@ -36,15 +36,15 @@
36
36
  "@reventlessdev/rescript-jest": "1.0.0-alpha.5",
37
37
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.16",
38
38
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
39
- "@reventlessdev/reventless-core": "3.0.0-alpha.136",
40
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.76",
41
- "@reventlessdev/reventless-infra": "3.0.0-alpha.83",
42
- "@reventlessdev/reventless-spec": "3.0.0-alpha.63"
39
+ "@reventlessdev/reventless-core": "3.0.0-alpha.137",
40
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.77",
41
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.84",
42
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.64"
43
43
  },
44
44
  "devDependencies": {
45
45
  "rescript": "^12.3.0",
46
46
  "sury-ppx": "11.0.0-alpha.2",
47
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.48"
47
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.50"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "rescript": "^12.3.0"
package/src/Platform.res CHANGED
@@ -28,6 +28,22 @@ let getPlatformGraphQL = () => platformGraphQLRef.contents
28
28
  // Populated by makePlatform when splitApi=true.
29
29
  let platformMCPRef: ref<option<MCP_ServerInstance.t>> = ref(None)
30
30
 
31
+ // Decode a Plugin aggregate event from the published bus envelope.
32
+ // The bus delivers the EventTopic envelope {id, meta, event} — the event
33
+ // payload lives under "event". (The previous code fed the WHOLE envelope to
34
+ // the event schema in convert mode, which "succeeded" and then threw a
35
+ // TypeError on every variant's payload access, so the Source-C status /
36
+ // UI-fragment subscription emissions never fired.) Top-level, not inside the
37
+ // functor, so the decode is unit-testable.
38
+ let decodePluginEventEnvelope = (eventJson: JSON.t): option<ReventlessCore.PluginSpec.event> =>
39
+ switch eventJson
40
+ ->JSON.Decode.object
41
+ ->Option.flatMap(d => d->Dict.get("event"))
42
+ ->Option.map(payload => payload->S.parseJsonOrThrow(ReventlessCore.PluginSpec.eventSchema)) {
43
+ | result => result
44
+ | exception _ => None
45
+ }
46
+
31
47
  // Configurable platform — set silent=true to suppress diagnostic warnings in tests,
32
48
  // splitApi=true to serve core and plugin APIs on separate ports,
33
49
  // backend=Backend.Sqlite({...}) to opt into file-backed SQLite persistence.
@@ -76,6 +92,29 @@ module MakeWithConfig = (
76
92
  LocalBus.seedEventTapSeq(
77
93
  EventLogStorage_Sqlite.countAll(db) + DcbEventLogStorage_Sqlite.countAll(db),
78
94
  )
95
+ // Projection checkpoints (plan B5): the event-log storage tracks each
96
+ // appended batch as pending; this hook resolves it once the publish cycle
97
+ // completes (publishEvent returns only after every subscriber processed the
98
+ // events), advancing the persisted per-read-model checkpoints to the
99
+ // low-watermark. Startup catch-up in makePlatform replays anything below a
100
+ // read model's checkpoint that a crash left unprojected.
101
+ ProjectionPending.enableTracking()
102
+ ReventlessCore.EventPublish_Callback.registerAfterPublish(async publishedEvent => {
103
+ // Aggregate EventLog batches carry flat stored events (msgId at the top
104
+ // level of each eventsJson entry); DcbEventLog batches carry bare event
105
+ // payloads and expose the per-event metas separately.
106
+ let msgIds = switch publishedEvent.metas {
107
+ | Some(metas) => metas->Array.map(m => m.msgId)
108
+ | None =>
109
+ publishedEvent.eventsJson->Array.filterMap(json =>
110
+ json
111
+ ->JSON.Decode.object
112
+ ->Option.flatMap(d => d->Dict.get("msgId"))
113
+ ->Option.flatMap(JSON.Decode.string)
114
+ )
115
+ }
116
+ ProjectionCheckpoint.completePublished(db, msgIds)
117
+ })
79
118
  }
80
119
 
81
120
  module Bus = LocalBus.Impl({
@@ -860,36 +899,36 @@ module MakeWithConfig = (
860
899
  let manifestJson = (m: Reventless.Plugin.uiFragmentManifest) =>
861
900
  m->S.reverseConvertToJsonOrThrow(Reventless.Plugin.uiFragmentManifestSchema)
862
901
  Bus.subscribeToEvents(pluginEventTopicKey, async (_service, _meta, eventJson) => {
863
- switch eventJson->S.convertOrThrow(ReventlessCore.PluginSpec.eventSchema) {
864
- | VersionConnected(def)
865
- | VersionActivated(def)
866
- | VersionPromoted(def) =>
902
+ switch decodePluginEventEnvelope(eventJson) {
903
+ | Some(VersionConnected(def))
904
+ | Some(VersionActivated(def))
905
+ | Some(VersionPromoted(def)) =>
867
906
  publishStatus(~name=def.name, ~status="Connected")
868
- | VersionDisconnected(def) => publishStatus(~name=def.name, ~status="Disconnected")
869
- | VersionDeactivated(def) => publishStatus(~name=def.name, ~status="Inactive")
870
- | VersionRetired(def) => publishStatus(~name=def.name, ~status="Retired")
871
- | UIFragmentRegistered(data) =>
907
+ | Some(VersionDisconnected(def)) => publishStatus(~name=def.name, ~status="Disconnected")
908
+ | Some(VersionDeactivated(def)) => publishStatus(~name=def.name, ~status="Inactive")
909
+ | Some(VersionRetired(def)) => publishStatus(~name=def.name, ~status="Retired")
910
+ | Some(UIFragmentRegistered(data)) =>
872
911
  publishUIFragment(
873
912
  ~name=ReventlessCore.Plugin.name(data.pluginId),
874
913
  ~changeKind="Registered",
875
914
  ~manifest=manifestJson(data.manifest),
876
915
  )
877
- | UIFragmentUpdated(data) =>
916
+ | Some(UIFragmentUpdated(data)) =>
878
917
  publishUIFragment(
879
918
  ~name=ReventlessCore.Plugin.name(data.pluginId),
880
919
  ~changeKind="Updated",
881
920
  ~manifest=manifestJson(data.newManifest),
882
921
  )
883
- | UIFragmentDeregistered(data) =>
922
+ | Some(UIFragmentDeregistered(data)) =>
884
923
  publishUIFragment(
885
924
  ~name=ReventlessCore.Plugin.name(data.pluginId),
886
925
  ~changeKind="Deregistered",
887
926
  ~manifest=JSON.Encode.null,
888
927
  )
889
- | VersionDetected(_)
890
- | VersionSuperseded(_)
891
- | IncompatiblePluginDetected(_) => ()
892
- | exception _ => ()
928
+ | Some(VersionDetected(_))
929
+ | Some(VersionSuperseded(_))
930
+ | Some(IncompatiblePluginDetected(_))
931
+ | None => ()
893
932
  }
894
933
  })
895
934
  }
@@ -1239,6 +1278,16 @@ module MakeWithConfig = (
1239
1278
  `backend: sqlite (${path}${resetOnStart ? ", resetOnStart" : ""})`
1240
1279
  },
1241
1280
  )
1281
+ // Projection catch-up bound (plan B5): the highest persisted event position
1282
+ // BEFORE this session appends anything. Catch-up replays only (checkpoint,
1283
+ // bound] — this session's own events (Connect dispatches, user commands)
1284
+ // are live-delivered and must not be redelivered.
1285
+ let projectionCatchup = BackendState.getDb()->Option.map(db => (
1286
+ db,
1287
+ ProjectionCheckpoint.maxPosition(db, ProjectionPending.Aggregate),
1288
+ ProjectionCheckpoint.maxPosition(db, ProjectionPending.Dcb),
1289
+ ))
1290
+
1242
1291
  // Create scheduler and populate platform context refs.
1243
1292
  let scheduler = makeScheduler()
1244
1293
  hooks.scheduler := Some(scheduler)
@@ -1328,9 +1377,34 @@ module MakeWithConfig = (
1328
1377
  // Drive the admin Plugin read models via synthetic Connect dispatch through
1329
1378
  // LocalPluginAggregate (replaces the old direct-write seed). The real
1330
1379
  // PluginsProjection folds the emitted events into the "Plugins" QueryDb store.
1331
- connectPlugin(~pluginComponents=plugins)
1332
- seedUIFragmentRegistryQueryDb(~pluginComponents=plugins)
1333
- seedPluginStructuresStore(~pluginComponents=plugins)
1380
+ //
1381
+ // Under SQLite this trio is sequenced AFTER the projection catch-up: the
1382
+ // catch-up replays PRIOR sessions' stored events (e.g. the admin plugin
1383
+ // lifecycle history) into the projections, and this session's Connect
1384
+ // events must fold on top of — never race and be overwritten by — that
1385
+ // replay. All three calls are internally fire-and-forget Output.apply
1386
+ // chains, so deferring their invocation changes no synchronous contract.
1387
+ let seedAdminStores = () => {
1388
+ connectPlugin(~pluginComponents=plugins)
1389
+ seedUIFragmentRegistryQueryDb(~pluginComponents=plugins)
1390
+ seedPluginStructuresStore(~pluginComponents=plugins)
1391
+ }
1392
+ switch projectionCatchup {
1393
+ | Some((db, upperBound, dcbUpperBound)) =>
1394
+ let _ =
1395
+ ProjectionCheckpoint.startupCatchup(~db, ~upperBound, ~dcbUpperBound, ~handlers=() =>
1396
+ Bus.projectionCatchupHandlers()
1397
+ )
1398
+ ->Promise.catch(e => {
1399
+ Console.error2("[Platform] projection catch-up failed:", e)
1400
+ Promise.resolve()
1401
+ })
1402
+ ->Promise.then(() => {
1403
+ seedAdminStores()
1404
+ Promise.resolve()
1405
+ })
1406
+ | None => seedAdminStores()
1407
+ }
1334
1408
 
1335
1409
  // Seed the built-in admin plugin's structure so its Auto UI (Plugin list with
1336
1410
  // Activate/Deactivate buttons) renders alongside the user plugins. Admin.construct
@@ -11,6 +11,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
11
11
  import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
12
12
  import * as Effect from "effect/Effect";
13
13
  import * as Pulumi from "@pulumi/pulumi";
14
+ import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
14
15
  import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
15
16
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
16
17
  import * as Plugin$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin.res.mjs";
@@ -45,12 +46,15 @@ import * as Scheduler_Builder$ReventlessCore from "@reventlessdev/reventless-cor
45
46
  import * as Aggregate_Builder$ReventlessLocal from "./components/Aggregate_Builder.res.mjs";
46
47
  import * as LocalClonerRunner$ReventlessLocal from "./adapter/Cloner/LocalClonerRunner.res.mjs";
47
48
  import * as PluginBaseFragment$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/api/PluginBaseFragment.res.mjs";
49
+ import * as ProjectionPending$ReventlessLocal from "./adapter/ProjectionPending.res.mjs";
48
50
  import * as ReadModel_Builder$ReventlessLocal from "./components/ReadModel_Builder.res.mjs";
49
51
  import * as PlatformMCP_Server$ReventlessLocal from "./adapter/PlatformMCP_Server.res.mjs";
50
52
  import * as Auth_GraphqlContext$ReventlessLocal from "./adapter/Auth/Auth_GraphqlContext.res.mjs";
51
53
  import * as PluginsReadModelSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs";
52
54
  import * as DomainGraphQL_Server$ReventlessLocal from "./adapter/DomainGraphQL_Server.res.mjs";
55
+ import * as EventPublish_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/EventLog/EventPublish_Callback.res.mjs";
53
56
  import * as LocalGraphQL_Adapter$ReventlessLocal from "./adapter/Api/LocalGraphQL_Adapter.res.mjs";
57
+ import * as ProjectionCheckpoint$ReventlessLocal from "./adapter/ProjectionCheckpoint.res.mjs";
54
58
  import * as ExtensionPointMapping$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/ExtensionPointMapping.res.mjs";
55
59
  import * as EventLogStorage_Sqlite$ReventlessLocal from "./adapter/EventLog/EventLogStorage_Sqlite.res.mjs";
56
60
  import * as ExtensionPoint_Builder$ReventlessLocal from "./components/ExtensionPoint_Builder.res.mjs";
@@ -93,6 +97,14 @@ let platformMCPRef = {
93
97
  contents: undefined
94
98
  };
95
99
 
100
+ function decodePluginEventEnvelope(eventJson) {
101
+ try {
102
+ return Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(eventJson), d => d["event"]), payload => S.parseJsonOrThrow(payload, PluginSpec$ReventlessCore.eventSchema));
103
+ } catch (exn) {
104
+ return;
105
+ }
106
+ }
107
+
96
108
  function MakeWithConfig(Config) {
97
109
  TestRunner$ReventlessLocal.setup();
98
110
  let match = Config.backend;
@@ -106,6 +118,12 @@ function MakeWithConfig(Config) {
106
118
  let db = SqliteDriver$ReventlessLocal.openDb(path);
107
119
  BackendState$ReventlessLocal.setSqlite(db, path);
108
120
  LocalBus$ReventlessLocal.seedEventTapSeq(EventLogStorage_Sqlite$ReventlessLocal.countAll(db) + DcbEventLogStorage_Sqlite$ReventlessLocal.countAll(db) | 0);
121
+ ProjectionPending$ReventlessLocal.enableTracking();
122
+ EventPublish_Callback$ReventlessCore.registerAfterPublish(async publishedEvent => {
123
+ let metas = publishedEvent.metas;
124
+ let msgIds = metas !== undefined ? metas.map(m => m.msgId) : Stdlib_Array.filterMap(publishedEvent.eventsJson, json => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), d => d["msgId"]), Stdlib_JSON.Decode.string));
125
+ return ProjectionCheckpoint$ReventlessLocal.completePublished(db, msgIds);
126
+ });
109
127
  }
110
128
  let Bus = LocalBus$ReventlessLocal.Impl({
111
129
  capacity: undefined,
@@ -637,6 +655,7 @@ function MakeWithConfig(Config) {
637
655
  initialState: PluginBehavior$ReventlessCore.initialState,
638
656
  evolve: PluginBehavior$ReventlessCore.evolve,
639
657
  decide: PluginBehavior$ReventlessCore.decide,
658
+ snapshot: undefined,
640
659
  moduleUrl: PluginBehavior$ReventlessCore.moduleUrl
641
660
  })(NoEventMappings$ReventlessInfra.Make({
642
661
  name: PluginSpec$ReventlessCore.name,
@@ -787,33 +806,31 @@ function MakeWithConfig(Config) {
787
806
  ]
788
807
  ]));
789
808
  Bus.subscribeToEvents(pluginEventTopicKey, async (_service, _meta, eventJson) => {
790
- let def;
791
- try {
792
- def = S.convertOrThrow(eventJson, PluginSpec$ReventlessCore.eventSchema);
793
- } catch (exn) {
809
+ let match = decodePluginEventEnvelope(eventJson);
810
+ if (match === undefined) {
794
811
  return;
795
812
  }
796
- switch (def.TAG) {
813
+ switch (match.TAG) {
797
814
  case "VersionDisconnected" :
798
- return publishStatus(def._0.name, "Disconnected");
815
+ return publishStatus(match._0.name, "Disconnected");
799
816
  case "VersionDeactivated" :
800
- return publishStatus(def._0.name, "Inactive");
817
+ return publishStatus(match._0.name, "Inactive");
801
818
  case "VersionRetired" :
802
- return publishStatus(def._0.name, "Retired");
819
+ return publishStatus(match._0.name, "Retired");
803
820
  case "VersionDetected" :
804
821
  case "VersionSuperseded" :
805
822
  case "IncompatiblePluginDetected" :
806
823
  return;
807
824
  case "UIFragmentRegistered" :
808
- let data = def._0;
825
+ let data = match._0;
809
826
  return publishUIFragment(Plugin$ReventlessCore.name(data.pluginId), "Registered", S.reverseConvertToJsonOrThrow(data.manifest, Plugin$Reventless.uiFragmentManifestSchema));
810
827
  case "UIFragmentUpdated" :
811
- let data$1 = def._0;
828
+ let data$1 = match._0;
812
829
  return publishUIFragment(Plugin$ReventlessCore.name(data$1.pluginId), "Updated", S.reverseConvertToJsonOrThrow(data$1.newManifest, Plugin$Reventless.uiFragmentManifestSchema));
813
830
  case "UIFragmentDeregistered" :
814
- return publishUIFragment(Plugin$ReventlessCore.name(def._0.pluginId), "Deregistered", null);
831
+ return publishUIFragment(Plugin$ReventlessCore.name(match._0.pluginId), "Deregistered", null);
815
832
  default:
816
- return publishStatus(def._0.name, "Connected");
833
+ return publishStatus(match._0.name, "Connected");
817
834
  }
818
835
  });
819
836
  };
@@ -1081,6 +1098,11 @@ function MakeWithConfig(Config) {
1081
1098
  match.resetOnStart ? ", resetOnStart" : ""
1082
1099
  ) + `)`;
1083
1100
  log.info("Platform", undefined, tmp);
1101
+ let projectionCatchup = Stdlib_Option.map(BackendState$ReventlessLocal.getDb(), db => [
1102
+ db,
1103
+ ProjectionCheckpoint$ReventlessLocal.maxPosition(db, "Aggregate"),
1104
+ ProjectionCheckpoint$ReventlessLocal.maxPosition(db, "Dcb")
1105
+ ]);
1084
1106
  let scheduler = makeScheduler();
1085
1107
  hooks_scheduler.contents = scheduler;
1086
1108
  hooks_api.contents = {
@@ -1139,9 +1161,22 @@ function MakeWithConfig(Config) {
1139
1161
  adminResources: []
1140
1162
  });
1141
1163
  subscribeToPluginEvents();
1142
- connectPlugin(plugins$1);
1143
- seedUIFragmentRegistryQueryDb(plugins$1);
1144
- seedPluginStructuresStore(plugins$1);
1164
+ let seedAdminStores = () => {
1165
+ connectPlugin(plugins$1);
1166
+ seedUIFragmentRegistryQueryDb(plugins$1);
1167
+ seedPluginStructuresStore(plugins$1);
1168
+ };
1169
+ if (projectionCatchup !== undefined) {
1170
+ Stdlib_Promise.$$catch(ProjectionCheckpoint$ReventlessLocal.startupCatchup(projectionCatchup[0], projectionCatchup[1], projectionCatchup[2], () => Bus.projectionCatchupHandlers()), e => {
1171
+ console.error("[Platform] projection catch-up failed:", e);
1172
+ return Promise.resolve();
1173
+ }).then(() => {
1174
+ seedAdminStores();
1175
+ return Promise.resolve();
1176
+ });
1177
+ } else {
1178
+ seedAdminStores();
1179
+ }
1145
1180
  pluginStructuresStore.contents[Platform_Admin_Structure$ReventlessCore.pluginId] = Platform_Admin_Structure$ReventlessCore.structure;
1146
1181
  CommandGeneratorResolvers_GraphQL$ReventlessLocal.setPluginStatusGate(field => {
1147
1182
  let parts = field.split("_");
@@ -1698,6 +1733,12 @@ function Make($star) {
1698
1733
  let db = SqliteDriver$ReventlessLocal.openDb(path);
1699
1734
  BackendState$ReventlessLocal.setSqlite(db, path);
1700
1735
  LocalBus$ReventlessLocal.seedEventTapSeq(EventLogStorage_Sqlite$ReventlessLocal.countAll(db) + DcbEventLogStorage_Sqlite$ReventlessLocal.countAll(db) | 0);
1736
+ ProjectionPending$ReventlessLocal.enableTracking();
1737
+ EventPublish_Callback$ReventlessCore.registerAfterPublish(async publishedEvent => {
1738
+ let metas = publishedEvent.metas;
1739
+ let msgIds = metas !== undefined ? metas.map(m => m.msgId) : Stdlib_Array.filterMap(publishedEvent.eventsJson, json => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), d => d["msgId"]), Stdlib_JSON.Decode.string));
1740
+ return ProjectionCheckpoint$ReventlessLocal.completePublished(db, msgIds);
1741
+ });
1701
1742
  }
1702
1743
  let Bus = LocalBus$ReventlessLocal.Impl({
1703
1744
  capacity: undefined,
@@ -2222,6 +2263,7 @@ function Make($star) {
2222
2263
  initialState: PluginBehavior$ReventlessCore.initialState,
2223
2264
  evolve: PluginBehavior$ReventlessCore.evolve,
2224
2265
  decide: PluginBehavior$ReventlessCore.decide,
2266
+ snapshot: undefined,
2225
2267
  moduleUrl: PluginBehavior$ReventlessCore.moduleUrl
2226
2268
  })(NoEventMappings$ReventlessInfra.Make({
2227
2269
  name: PluginSpec$ReventlessCore.name,
@@ -2372,33 +2414,31 @@ function Make($star) {
2372
2414
  ]
2373
2415
  ]));
2374
2416
  Bus.subscribeToEvents(pluginEventTopicKey, async (_service, _meta, eventJson) => {
2375
- let def;
2376
- try {
2377
- def = S.convertOrThrow(eventJson, PluginSpec$ReventlessCore.eventSchema);
2378
- } catch (exn) {
2417
+ let match = decodePluginEventEnvelope(eventJson);
2418
+ if (match === undefined) {
2379
2419
  return;
2380
2420
  }
2381
- switch (def.TAG) {
2421
+ switch (match.TAG) {
2382
2422
  case "VersionDisconnected" :
2383
- return publishStatus(def._0.name, "Disconnected");
2423
+ return publishStatus(match._0.name, "Disconnected");
2384
2424
  case "VersionDeactivated" :
2385
- return publishStatus(def._0.name, "Inactive");
2425
+ return publishStatus(match._0.name, "Inactive");
2386
2426
  case "VersionRetired" :
2387
- return publishStatus(def._0.name, "Retired");
2427
+ return publishStatus(match._0.name, "Retired");
2388
2428
  case "VersionDetected" :
2389
2429
  case "VersionSuperseded" :
2390
2430
  case "IncompatiblePluginDetected" :
2391
2431
  return;
2392
2432
  case "UIFragmentRegistered" :
2393
- let data = def._0;
2433
+ let data = match._0;
2394
2434
  return publishUIFragment(Plugin$ReventlessCore.name(data.pluginId), "Registered", S.reverseConvertToJsonOrThrow(data.manifest, Plugin$Reventless.uiFragmentManifestSchema));
2395
2435
  case "UIFragmentUpdated" :
2396
- let data$1 = def._0;
2436
+ let data$1 = match._0;
2397
2437
  return publishUIFragment(Plugin$ReventlessCore.name(data$1.pluginId), "Updated", S.reverseConvertToJsonOrThrow(data$1.newManifest, Plugin$Reventless.uiFragmentManifestSchema));
2398
2438
  case "UIFragmentDeregistered" :
2399
- return publishUIFragment(Plugin$ReventlessCore.name(def._0.pluginId), "Deregistered", null);
2439
+ return publishUIFragment(Plugin$ReventlessCore.name(match._0.pluginId), "Deregistered", null);
2400
2440
  default:
2401
- return publishStatus(def._0.name, "Connected");
2441
+ return publishStatus(match._0.name, "Connected");
2402
2442
  }
2403
2443
  });
2404
2444
  };
@@ -2661,6 +2701,11 @@ function Make($star) {
2661
2701
  backend.resetOnStart ? ", resetOnStart" : ""
2662
2702
  ) + `)`;
2663
2703
  log.info("Platform", undefined, tmp);
2704
+ let projectionCatchup = Stdlib_Option.map(BackendState$ReventlessLocal.getDb(), db => [
2705
+ db,
2706
+ ProjectionCheckpoint$ReventlessLocal.maxPosition(db, "Aggregate"),
2707
+ ProjectionCheckpoint$ReventlessLocal.maxPosition(db, "Dcb")
2708
+ ]);
2664
2709
  let scheduler = makeScheduler();
2665
2710
  hooks_scheduler.contents = scheduler;
2666
2711
  hooks_api.contents = {
@@ -2719,9 +2764,22 @@ function Make($star) {
2719
2764
  adminResources: []
2720
2765
  });
2721
2766
  subscribeToPluginEvents();
2722
- connectPlugin(plugins$1);
2723
- seedUIFragmentRegistryQueryDb(plugins$1);
2724
- seedPluginStructuresStore(plugins$1);
2767
+ let seedAdminStores = () => {
2768
+ connectPlugin(plugins$1);
2769
+ seedUIFragmentRegistryQueryDb(plugins$1);
2770
+ seedPluginStructuresStore(plugins$1);
2771
+ };
2772
+ if (projectionCatchup !== undefined) {
2773
+ Stdlib_Promise.$$catch(ProjectionCheckpoint$ReventlessLocal.startupCatchup(projectionCatchup[0], projectionCatchup[1], projectionCatchup[2], () => Bus.projectionCatchupHandlers()), e => {
2774
+ console.error("[Platform] projection catch-up failed:", e);
2775
+ return Promise.resolve();
2776
+ }).then(() => {
2777
+ seedAdminStores();
2778
+ return Promise.resolve();
2779
+ });
2780
+ } else {
2781
+ seedAdminStores();
2782
+ }
2725
2783
  pluginStructuresStore.contents[Platform_Admin_Structure$ReventlessCore.pluginId] = Platform_Admin_Structure$ReventlessCore.structure;
2726
2784
  CommandGeneratorResolvers_GraphQL$ReventlessLocal.setPluginStatusGate(field => {
2727
2785
  let parts = field.split("_");
@@ -3256,6 +3314,7 @@ export {
3256
3314
  platformGraphQLRef,
3257
3315
  getPlatformGraphQL,
3258
3316
  platformMCPRef,
3317
+ decodePluginEventEnvelope,
3259
3318
  MakeWithConfig,
3260
3319
  Make,
3261
3320
  }
@@ -25,18 +25,108 @@ let matchesQuery = (event: DcbEventLog_Adapter.rawSequencedEvent, query: Reventl
25
25
  })
26
26
  }
27
27
 
28
+ // Composite key for the per-(key,value) tag posting list. NUL can't appear in a
29
+ // tag key or value, so it's an unambiguous separator.
30
+ let tagPostingKey = (key: string, value: string) => key ++ "\x00" ++ value
31
+
32
+ // Intersect two ascending, duplicate-free index arrays (two-pointer merge).
33
+ let intersectSorted = (a: array<int>, b: array<int>): array<int> => {
34
+ let out = []
35
+ let i = ref(0)
36
+ let j = ref(0)
37
+ while i.contents < a->Array.length && j.contents < b->Array.length {
38
+ let av = a->Array.getUnsafe(i.contents)
39
+ let bv = b->Array.getUnsafe(j.contents)
40
+ if av == bv {
41
+ out->Array.push(av)
42
+ i := i.contents + 1
43
+ j := j.contents + 1
44
+ } else if av < bv {
45
+ i := i.contents + 1
46
+ } else {
47
+ j := j.contents + 1
48
+ }
49
+ }
50
+ out
51
+ }
52
+
28
53
  let makeStorage = (~name as _name, ~indexes as _, ~partitionTag as _, ~opts as _) => {
29
54
  let events: ref<array<DcbEventLog_Adapter.rawSequencedEvent>> = ref([])
30
55
  let position = ref(0)
56
+ // Posting lists: tag "key\0value" / eventType → ascending array indices into
57
+ // `events`. Events are only ever appended (never removed) in position order, so
58
+ // every list stays sorted and duplicate-free by construction. They turn the
59
+ // per-read / per-conditional-append full scan (O(n) → O(n²) over a session)
60
+ // into a lookup + intersection over the (usually tiny) candidate set.
61
+ let byTag: Dict.t<array<int>> = Dict.make()
62
+ let byType: Dict.t<array<int>> = Dict.make()
63
+ let pushInto = (dict: Dict.t<array<int>>, key: string, idx: int) =>
64
+ switch dict->Dict.get(key) {
65
+ | Some(list) => list->Array.push(idx)
66
+ | None => dict->Dict.set(key, [idx])
67
+ }
68
+ let indexEvent = (idx: int, ev: DcbEventLog_Adapter.rawSequencedEvent) => {
69
+ pushInto(byType, ev.eventType, idx)
70
+ ev.tags->Array.forEach(t => pushInto(byTag, tagPostingKey(t.key, t.value), idx))
71
+ }
31
72
 
32
- let read = async (~query, ~after=?) => {
33
- let filtered = events.contents->Array.filter(event => {
73
+ // Ascending, duplicate-free superset of the indices that could satisfy the
74
+ // query (an OR of clauses). Narrowing only — the authoritative `matchesQuery`
75
+ // predicate is still applied to each candidate, so the result is identical to
76
+ // the old linear scan even if this over-includes. Callers guarantee query≠[].
77
+ let candidateIndices = (query: Reventless.DcbTag.query): array<int> => {
78
+ let acc: Dict.t<int> = Dict.make()
79
+ let addAll = (arr: array<int>) => arr->Array.forEach(i => acc->Dict.set(Int.toString(i), i))
80
+ query->Array.forEach(clause => {
81
+ switch clause.tags {
82
+ | Some(tags) if tags->Array.length > 0 =>
83
+ // Events carrying ALL of the clause's tags = intersection of the tags'
84
+ // posting lists; a missing tag ⇒ empty ⇒ this clause adds nothing.
85
+ let lists = tags->Array.map(t =>
86
+ byTag->Dict.get(tagPostingKey(t.key, t.value))->Option.getOr([])
87
+ )
88
+ if lists->Array.some(l => l->Array.length == 0) {
89
+ ()
90
+ } else {
91
+ let sorted = lists->Array.toSorted((a, b) => Int.toFloat(a->Array.length - b->Array.length))
92
+ let acc0 = sorted->Array.getUnsafe(0)
93
+ let folded =
94
+ sorted->Array.slice(~start=1, ~end=sorted->Array.length)->Array.reduce(acc0, intersectSorted)
95
+ addAll(folded)
96
+ }
97
+ | _ =>
98
+ // No tag constraint: narrow by event type when present (union of the
99
+ // types' posting lists — disjoint since an event has one type), else the
100
+ // clause is unconstrained and matches everything.
101
+ switch clause.eventTypes {
102
+ | Some(types) => types->Array.forEach(t => addAll(byType->Dict.get(t)->Option.getOr([])))
103
+ | None => events.contents->Array.forEachWithIndex((_, i) => acc->Dict.set(Int.toString(i), i))
104
+ }
105
+ }
106
+ })
107
+ acc->Dict.valuesToArray->Array.toSorted((a, b) => Int.toFloat(a - b))
108
+ }
109
+
110
+ let matchingEvents = (~query, ~after): array<DcbEventLog_Adapter.rawSequencedEvent> => {
111
+ let pass = (ev: DcbEventLog_Adapter.rawSequencedEvent) => {
34
112
  let afterMatch = switch after {
35
- | Some(afterPos) => event.position->posToInt > afterPos->posToInt
113
+ | Some(afterPos) => ev.position->posToInt > afterPos->posToInt
36
114
  | None => true
37
115
  }
38
- afterMatch && matchesQuery(event, query)
39
- })
116
+ afterMatch && matchesQuery(ev, query)
117
+ }
118
+ if query->Array.length == 0 {
119
+ events.contents->Array.filter(pass)
120
+ } else {
121
+ candidateIndices(query)->Array.filterMap(i => {
122
+ let ev = events.contents->Array.getUnsafe(i)
123
+ pass(ev) ? Some(ev) : None
124
+ })
125
+ }
126
+ }
127
+
128
+ let read = async (~query, ~after=?) => {
129
+ let filtered = matchingEvents(~query, ~after)
40
130
  let headPosition =
41
131
  events.contents
42
132
  ->Array.get(events.contents->Array.length - 1)
@@ -50,30 +140,27 @@ let makeStorage = (~name as _name, ~indexes as _, ~partitionTag as _, ~opts as _
50
140
  let append = async (newEvents, ~condition=?) => {
51
141
  let conflictDetected = switch condition {
52
142
  | Some(cond: Reventless.DcbTag.appendCondition) =>
53
- events.contents->Array.some(event => {
54
- let afterMatch = switch cond.after {
55
- | Some(pos) => event.position->posToInt > pos->posToInt
56
- | None => true
57
- }
58
- afterMatch && matchesQuery(event, cond.query)
59
- })
143
+ matchingEvents(~query=cond.query, ~after=cond.after)->Array.length > 0
60
144
  | None => false
61
145
  }
62
146
  if conflictDetected {
63
147
  Error("conflict: condition check failed")
64
148
  } else {
149
+ let base = events.contents->Array.length
65
150
  let storedEvents =
66
- newEvents->Array.map((event: DcbEventLog_Adapter.rawStoredEvent) => {
151
+ newEvents->Array.mapWithIndex((event: DcbEventLog_Adapter.rawStoredEvent, j) => {
67
152
  position := position.contents + 1
68
153
  let pos = position.contents->Int.toString
69
- {
70
- DcbEventLog_Adapter.position: pos,
154
+ let stored: DcbEventLog_Adapter.rawSequencedEvent = {
155
+ position: pos,
71
156
  eventType: event.eventType,
72
157
  data: event.data,
73
158
  tags: event.tags,
74
159
  meta: event.meta,
75
160
  recordedAt: Message.nowAsISOString(),
76
161
  }
162
+ indexEvent(base + j, stored)
163
+ stored
77
164
  })
78
165
  events := events.contents->Array.concat(storedEvents)
79
166
  Ok(position.contents->Int.toString)
@@ -113,8 +200,6 @@ module Make = (Bus: LocalBus.T) => {
113
200
  | Some(db) =>
114
201
  let (storageName, read, storage) = DcbEventLogStorage_Sqlite.makeStorage(
115
202
  ~db,
116
- ~bus=(_, _) => (),
117
- ~publishToTopic=(~topicName as _, ~json as _) => (),
118
203
  ~name,
119
204
  ~indexes,
120
205
  ~partitionTag,