@reventlessdev/reventless-postgres 3.0.0-alpha.1

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 (128) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +202 -0
  3. package/__mocks__/emptyModule.js +1 -0
  4. package/lib/bs/.compiler.log +2 -0
  5. package/lib/bs/ReventlessPostgres.cmi +0 -0
  6. package/lib/bs/ReventlessPostgres.cmj +0 -0
  7. package/lib/bs/ReventlessPostgres.cmt +0 -0
  8. package/lib/bs/ReventlessPostgres.mlmap +9 -0
  9. package/lib/bs/build.ninja +0 -0
  10. package/lib/bs/compiler-info.json +8 -0
  11. package/lib/bs/src/DcbEventLogStorage_Postgres-ReventlessPostgres.cmi +0 -0
  12. package/lib/bs/src/DcbEventLogStorage_Postgres-ReventlessPostgres.cmj +0 -0
  13. package/lib/bs/src/DcbEventLogStorage_Postgres-ReventlessPostgres.cmt +0 -0
  14. package/lib/bs/src/DcbEventLogStorage_Postgres.ast +0 -0
  15. package/lib/bs/src/DcbEventLogStorage_Postgres.res +352 -0
  16. package/lib/bs/src/DcbEventLogStorage_Postgres.res.mjs +385 -0
  17. package/lib/bs/src/EventLogStorage_Postgres-ReventlessPostgres.cmi +0 -0
  18. package/lib/bs/src/EventLogStorage_Postgres-ReventlessPostgres.cmj +0 -0
  19. package/lib/bs/src/EventLogStorage_Postgres-ReventlessPostgres.cmt +0 -0
  20. package/lib/bs/src/EventLogStorage_Postgres.ast +0 -0
  21. package/lib/bs/src/EventLogStorage_Postgres.res +176 -0
  22. package/lib/bs/src/EventLogStorage_Postgres.res.mjs +207 -0
  23. package/lib/bs/src/PgChangeFeed-ReventlessPostgres.cmi +0 -0
  24. package/lib/bs/src/PgChangeFeed-ReventlessPostgres.cmj +0 -0
  25. package/lib/bs/src/PgChangeFeed-ReventlessPostgres.cmt +0 -0
  26. package/lib/bs/src/PgChangeFeed.ast +0 -0
  27. package/lib/bs/src/PgChangeFeed.res +145 -0
  28. package/lib/bs/src/PgChangeFeed.res.mjs +90 -0
  29. package/lib/bs/src/PgDriver-ReventlessPostgres.cmi +0 -0
  30. package/lib/bs/src/PgDriver-ReventlessPostgres.cmj +0 -0
  31. package/lib/bs/src/PgDriver-ReventlessPostgres.cmt +0 -0
  32. package/lib/bs/src/PgDriver-ReventlessPostgres.cmti +0 -0
  33. package/lib/bs/src/PgDriver.ast +0 -0
  34. package/lib/bs/src/PgDriver.iast +0 -0
  35. package/lib/bs/src/PgDriver.res +111 -0
  36. package/lib/bs/src/PgDriver.res.mjs +85 -0
  37. package/lib/bs/src/PgDriver.resi +68 -0
  38. package/lib/bs/src/PgSchema-ReventlessPostgres.cmi +0 -0
  39. package/lib/bs/src/PgSchema-ReventlessPostgres.cmj +0 -0
  40. package/lib/bs/src/PgSchema-ReventlessPostgres.cmt +0 -0
  41. package/lib/bs/src/PgSchema.ast +0 -0
  42. package/lib/bs/src/PgSchema.res +256 -0
  43. package/lib/bs/src/PgSchema.res.mjs +46 -0
  44. package/lib/bs/src/QueryDbStorage_Postgres-ReventlessPostgres.cmi +0 -0
  45. package/lib/bs/src/QueryDbStorage_Postgres-ReventlessPostgres.cmj +0 -0
  46. package/lib/bs/src/QueryDbStorage_Postgres-ReventlessPostgres.cmt +0 -0
  47. package/lib/bs/src/QueryDbStorage_Postgres.ast +0 -0
  48. package/lib/bs/src/QueryDbStorage_Postgres.res +244 -0
  49. package/lib/bs/src/QueryDbStorage_Postgres.res.mjs +225 -0
  50. package/lib/bs/src/QueryEnginePostgres-ReventlessPostgres.cmi +0 -0
  51. package/lib/bs/src/QueryEnginePostgres-ReventlessPostgres.cmj +0 -0
  52. package/lib/bs/src/QueryEnginePostgres-ReventlessPostgres.cmt +0 -0
  53. package/lib/bs/src/QueryEnginePostgres.ast +0 -0
  54. package/lib/bs/src/QueryEnginePostgres.res +127 -0
  55. package/lib/bs/src/QueryEnginePostgres.res.mjs +172 -0
  56. package/lib/bs/tests/PostgresIntegrationTest-ReventlessPostgres.cmi +0 -0
  57. package/lib/bs/tests/PostgresIntegrationTest-ReventlessPostgres.cmj +0 -0
  58. package/lib/bs/tests/PostgresIntegrationTest-ReventlessPostgres.cmt +0 -0
  59. package/lib/bs/tests/PostgresIntegrationTest.ast +0 -0
  60. package/lib/bs/tests/PostgresIntegrationTest.res +236 -0
  61. package/lib/bs/tests/PostgresIntegrationTest.res.mjs +328 -0
  62. package/lib/ocaml/.compiler.log +2 -0
  63. package/lib/ocaml/DcbEventLogStorage_Postgres-ReventlessPostgres.cmi +0 -0
  64. package/lib/ocaml/DcbEventLogStorage_Postgres-ReventlessPostgres.cmj +0 -0
  65. package/lib/ocaml/DcbEventLogStorage_Postgres-ReventlessPostgres.cmt +0 -0
  66. package/lib/ocaml/DcbEventLogStorage_Postgres.ast +0 -0
  67. package/lib/ocaml/DcbEventLogStorage_Postgres.res +352 -0
  68. package/lib/ocaml/EventLogStorage_Postgres-ReventlessPostgres.cmi +0 -0
  69. package/lib/ocaml/EventLogStorage_Postgres-ReventlessPostgres.cmj +0 -0
  70. package/lib/ocaml/EventLogStorage_Postgres-ReventlessPostgres.cmt +0 -0
  71. package/lib/ocaml/EventLogStorage_Postgres.ast +0 -0
  72. package/lib/ocaml/EventLogStorage_Postgres.res +176 -0
  73. package/lib/ocaml/PgChangeFeed-ReventlessPostgres.cmi +0 -0
  74. package/lib/ocaml/PgChangeFeed-ReventlessPostgres.cmj +0 -0
  75. package/lib/ocaml/PgChangeFeed-ReventlessPostgres.cmt +0 -0
  76. package/lib/ocaml/PgChangeFeed.ast +0 -0
  77. package/lib/ocaml/PgChangeFeed.res +145 -0
  78. package/lib/ocaml/PgDriver-ReventlessPostgres.cmi +0 -0
  79. package/lib/ocaml/PgDriver-ReventlessPostgres.cmj +0 -0
  80. package/lib/ocaml/PgDriver-ReventlessPostgres.cmt +0 -0
  81. package/lib/ocaml/PgDriver-ReventlessPostgres.cmti +0 -0
  82. package/lib/ocaml/PgDriver.ast +0 -0
  83. package/lib/ocaml/PgDriver.iast +0 -0
  84. package/lib/ocaml/PgDriver.res +111 -0
  85. package/lib/ocaml/PgDriver.resi +68 -0
  86. package/lib/ocaml/PgSchema-ReventlessPostgres.cmi +0 -0
  87. package/lib/ocaml/PgSchema-ReventlessPostgres.cmj +0 -0
  88. package/lib/ocaml/PgSchema-ReventlessPostgres.cmt +0 -0
  89. package/lib/ocaml/PgSchema.ast +0 -0
  90. package/lib/ocaml/PgSchema.res +256 -0
  91. package/lib/ocaml/PostgresIntegrationTest-ReventlessPostgres.cmi +0 -0
  92. package/lib/ocaml/PostgresIntegrationTest-ReventlessPostgres.cmj +0 -0
  93. package/lib/ocaml/PostgresIntegrationTest-ReventlessPostgres.cmt +0 -0
  94. package/lib/ocaml/PostgresIntegrationTest.ast +0 -0
  95. package/lib/ocaml/PostgresIntegrationTest.res +236 -0
  96. package/lib/ocaml/QueryDbStorage_Postgres-ReventlessPostgres.cmi +0 -0
  97. package/lib/ocaml/QueryDbStorage_Postgres-ReventlessPostgres.cmj +0 -0
  98. package/lib/ocaml/QueryDbStorage_Postgres-ReventlessPostgres.cmt +0 -0
  99. package/lib/ocaml/QueryDbStorage_Postgres.ast +0 -0
  100. package/lib/ocaml/QueryDbStorage_Postgres.res +244 -0
  101. package/lib/ocaml/QueryEnginePostgres-ReventlessPostgres.cmi +0 -0
  102. package/lib/ocaml/QueryEnginePostgres-ReventlessPostgres.cmj +0 -0
  103. package/lib/ocaml/QueryEnginePostgres-ReventlessPostgres.cmt +0 -0
  104. package/lib/ocaml/QueryEnginePostgres.ast +0 -0
  105. package/lib/ocaml/QueryEnginePostgres.res +127 -0
  106. package/lib/ocaml/ReventlessPostgres.cmi +0 -0
  107. package/lib/ocaml/ReventlessPostgres.cmj +0 -0
  108. package/lib/ocaml/ReventlessPostgres.cmt +0 -0
  109. package/lib/ocaml/ReventlessPostgres.mlmap +9 -0
  110. package/package.json +56 -0
  111. package/rescript.json +36 -0
  112. package/src/DcbEventLogStorage_Postgres.res +352 -0
  113. package/src/DcbEventLogStorage_Postgres.res.mjs +385 -0
  114. package/src/EventLogStorage_Postgres.res +176 -0
  115. package/src/EventLogStorage_Postgres.res.mjs +207 -0
  116. package/src/PgChangeFeed.res +145 -0
  117. package/src/PgChangeFeed.res.mjs +90 -0
  118. package/src/PgDriver.res +111 -0
  119. package/src/PgDriver.res.mjs +85 -0
  120. package/src/PgDriver.resi +68 -0
  121. package/src/PgSchema.res +256 -0
  122. package/src/PgSchema.res.mjs +46 -0
  123. package/src/QueryDbStorage_Postgres.res +244 -0
  124. package/src/QueryDbStorage_Postgres.res.mjs +225 -0
  125. package/src/QueryEnginePostgres.res +127 -0
  126. package/src/QueryEnginePostgres.res.mjs +172 -0
  127. package/tests/PostgresIntegrationTest.res +236 -0
  128. package/tests/PostgresIntegrationTest.res.mjs +328 -0
@@ -0,0 +1,236 @@
1
+ // Live-Postgres integration + concurrency suite (Phase F2).
2
+ //
3
+ // Skipped unless PG_URL is set, so the default `pnpm test` stays dependency-free.
4
+ // Run against a real database with e.g.:
5
+ // PG_URL=postgres://postgres:postgres@localhost:5432/postgres pnpm test
6
+ //
7
+ // Covers the concurrency-critical core that nothing else in the repo has:
8
+ // - classic OCC append (success, gap/stale → Conflict, replay)
9
+ // - DCB append + xmin-fenced read + conditional-append conflict
10
+ // - write-skew: two genuinely concurrent conditional appends on one boundary —
11
+ // exactly one commits, the other conflicts cleanly (the anomaly the advisory
12
+ // locks close)
13
+ // - both #AdvisoryLocks and #RowLocks strategies
14
+
15
+ open JestGlobals
16
+ open ReventlessCore
17
+ open Reventless
18
+
19
+ @val external processEnv: dict<string> = "process.env"
20
+ let opts: Pulumi.CustomResourceOptions.t = {}
21
+
22
+ let jsonObj = pairs => JSON.Encode.object(Dict.fromArray(pairs))
23
+
24
+ let stored = (eventType, tags, data): DcbEventLog_Adapter.rawStoredEvent => {
25
+ eventType,
26
+ data,
27
+ tags,
28
+ meta: ReventlessCore.Message.generateMeta(~service="pg-test"),
29
+ }
30
+
31
+ let evJson = (msgId, v) =>
32
+ jsonObj([("msgId", JSON.Encode.string(msgId)), ("v", JSON.Encode.int(v))])
33
+
34
+ switch processEnv->Dict.get("PG_URL") {
35
+ | None =>
36
+ testSync("Postgres integration (skipped — set PG_URL to run)", () => expect(true)->toBe(true))
37
+ | Some(url) =>
38
+ let pool = PgDriver.makePool({connectionString: url})
39
+
40
+ beforeAllAsync(async () => {
41
+ await PgSchema.ensureSchema(pool)
42
+ await PgSchema.truncateAll(pool)
43
+ })
44
+ afterAll(() => {
45
+ let _ = pool->PgDriver.endPool
46
+ })
47
+
48
+ describe("classic OCC event log", () => {
49
+ testPromise("append, replay, and conflict on wrong seq", async () => {
50
+ let (_n, ops, _s) = EventLogStorage_Postgres.makeStorage(~pool, ~name="ct-orders", ~opts)
51
+
52
+ let r0 = await ops.append(0, "agg-1", [evJson("m0", 1), evJson("m1", 2)])
53
+ switch r0 {
54
+ | Ok() => expect(true)->toBe(true)
55
+ | Error(_) => expect("append")->toBe("ok")
56
+ }
57
+
58
+ // Stale seq (0 again) → Conflict.
59
+ let rStale = await ops.append(0, "agg-1", [evJson("m2", 3)])
60
+ switch rStale {
61
+ | Error(EventLog.Conflict) => expect(true)->toBe(true)
62
+ | _ => expect("stale")->toBe("conflict")
63
+ }
64
+
65
+ // Gap seq (5) → Conflict.
66
+ let rGap = await ops.append(5, "agg-1", [evJson("m3", 4)])
67
+ switch rGap {
68
+ | Error(EventLog.Conflict) => expect(true)->toBe(true)
69
+ | _ => expect("gap")->toBe("conflict")
70
+ }
71
+
72
+ // Correct next seq (2) → Ok.
73
+ let rNext = await ops.append(2, "agg-1", [evJson("m4", 5)])
74
+ switch rNext {
75
+ | Ok() => expect(true)->toBe(true)
76
+ | Error(_) => expect("next")->toBe("ok")
77
+ }
78
+
79
+ let evs = await ops.replay("agg-1")
80
+ expect(evs->Array.length)->toBe(3)
81
+
82
+ // Empty append is a no-op, never a conflict.
83
+ let rEmpty = await ops.append(3, "agg-1", [])
84
+ switch rEmpty {
85
+ | Ok() => expect(true)->toBe(true)
86
+ | Error(_) => expect("empty")->toBe("ok")
87
+ }
88
+ })
89
+ })
90
+
91
+ describe("DCB event log", () => {
92
+ let makeLog = name =>
93
+ DcbEventLogStorage_Postgres.makeStorage(
94
+ ~pool,
95
+ ~name,
96
+ ~indexes=[],
97
+ ~partitionTag=DcbTag.Simple({key: "k"}),
98
+ ~opts,
99
+ )
100
+
101
+ testPromise("append + read round-trips events with tags", async () => {
102
+ let (_n, ops, _s) = makeLog("dcb-rt")
103
+ let e1 = stored("ItemAdded", [{key: "itemId", value: "x1"}], jsonObj([("n", JSON.Encode.string("w"))]))
104
+ let r = await ops.append([e1])
105
+ switch r {
106
+ | Ok(_) => expect(true)->toBe(true)
107
+ | Error(m) => expect(m)->toBe("ok")
108
+ }
109
+ let read = await ops.read(~query=[])
110
+ expect(read.events->Array.length)->toBe(1)
111
+ let first = read.events->Array.getUnsafe(0)
112
+ expect(first.eventType)->toBe("ItemAdded")
113
+ expect((first.tags->Array.getUnsafe(0)).key)->toBe("itemId")
114
+ expect((first.tags->Array.getUnsafe(0)).value)->toBe("x1")
115
+ })
116
+
117
+ testPromise("conditional append conflicts when the boundary already has an event", async () => {
118
+ let (_n, ops, _s) = makeLog("dcb-cond")
119
+ let mk = who => stored("Reserved", [{key: "seatId", value: "s1"}], jsonObj([("who", JSON.Encode.string(who))]))
120
+ let r1 = await ops.append([mk("a")])
121
+ switch r1 {
122
+ | Ok(_) => expect(true)->toBe(true)
123
+ | Error(_) => expect("first")->toBe("ok")
124
+ }
125
+ // Assert "no Reserved for s1 yet" — must now conflict.
126
+ let cond: DcbTag.appendCondition = {
127
+ query: [{eventTypes: ["Reserved"], tags: [{key: "seatId", value: "s1"}]}],
128
+ }
129
+ let r2 = await ops.append([mk("b")], ~condition=cond)
130
+ switch r2 {
131
+ | Error(ReventlessInfra.DcbEventLog.Conflict) => expect(true)->toBe(true)
132
+ | _ => expect("second")->toBe("conflict")
133
+ }
134
+ })
135
+
136
+ let writeSkew = (strategy, logName) =>
137
+ testPromise(`write-skew: exactly one of two concurrent appends wins (${logName})`, async () => {
138
+ let (_n, ops, _s) = DcbEventLogStorage_Postgres.makeStorage(
139
+ ~pool,
140
+ ~name=logName,
141
+ ~indexes=[],
142
+ ~partitionTag=DcbTag.Simple({key: "k"}),
143
+ ~opts,
144
+ ~lockStrategy=strategy,
145
+ )
146
+ let cond: DcbTag.appendCondition = {
147
+ query: [{eventTypes: ["Reserved"], tags: [{key: "seatId", value: "hot"}]}],
148
+ }
149
+ let mk = who => stored("Reserved", [{key: "seatId", value: "hot"}], jsonObj([("who", JSON.Encode.string(who))]))
150
+ // Dispatch BOTH before awaiting either — genuinely concurrent on the pool.
151
+ let p1 = ops.append([mk("a")], ~condition=cond)
152
+ let p2 = ops.append([mk("b")], ~condition=cond)
153
+ let r1 = await p1
154
+ let r2 = await p2
155
+ let isOk = r => switch r {
156
+ | Ok(_) => true
157
+ | _ => false
158
+ }
159
+ let isConflict = r => r == Error(ReventlessInfra.DcbEventLog.Conflict)
160
+ let oks = [r1, r2]->Array.filter(isOk)->Array.length
161
+ let conflicts = [r1, r2]->Array.filter(isConflict)->Array.length
162
+ expect(oks)->toBe(1)
163
+ expect(conflicts)->toBe(1)
164
+ // And only one event actually landed on the boundary.
165
+ let read = await ops.read(~query=[{tags: [{key: "seatId", value: "hot"}]}])
166
+ expect(read.events->Array.length)->toBe(1)
167
+ })
168
+
169
+ writeSkew(#AdvisoryLocks, "dcb-skew-advisory")
170
+ writeSkew(#RowLocks, "dcb-skew-rows")
171
+
172
+ testPromise("hot tag: N concurrent conditional appends, exactly one wins", async () => {
173
+ let (_n, ops, _s) = makeLog("dcb-hot")
174
+ let cond: DcbTag.appendCondition = {
175
+ query: [{eventTypes: ["Claimed"], tags: [{key: "prizeId", value: "p1"}]}],
176
+ }
177
+ let n = 12
178
+ // Dispatch all N before awaiting any — genuinely concurrent on the pool.
179
+ let promises = Array.make(~length=n, 0)->Array.mapWithIndex((_, i) =>
180
+ ops.append(
181
+ [stored("Claimed", [{key: "prizeId", value: "p1"}], jsonObj([("who", JSON.Encode.int(i))]))],
182
+ ~condition=cond,
183
+ )
184
+ )
185
+ let results = await Promise.all(promises)
186
+ let oks = results->Array.filter(r => switch r {
187
+ | Ok(_) => true
188
+ | _ => false
189
+ })->Array.length
190
+ expect(oks)->toBe(1)
191
+ // No lost updates: exactly one Claimed landed on the boundary.
192
+ let read = await ops.read(~query=[{tags: [{key: "prizeId", value: "p1"}]}])
193
+ expect(read.events->Array.length)->toBe(1)
194
+ })
195
+
196
+ testPromise("cursors are monotonic and usable as `after`", async () => {
197
+ let (_n, ops, _s) = makeLog("dcb-cursor")
198
+ let mk = i => stored("Tick", [{key: "streamId", value: "c1"}], jsonObj([("i", JSON.Encode.int(i))]))
199
+ let _ = await ops.append([mk(0)])
200
+ let mid = switch await ops.append([mk(1)]) {
201
+ | Ok(pos) => pos
202
+ | Error(_) => ""
203
+ }
204
+ let _ = await ops.append([mk(2)])
205
+ // Reading after `mid` returns only the events strictly after it.
206
+ let read = await ops.read(~query=[{tags: [{key: "streamId", value: "c1"}]}], ~after=mid)
207
+ expect(read.events->Array.length)->toBe(1)
208
+ expect((read.events->Array.getUnsafe(0)).eventType)->toBe("Tick")
209
+ })
210
+ })
211
+
212
+ describe("change feed", () => {
213
+ testPromise("drains all events and checkpoints", async () => {
214
+ let (_n, ops, _s) = DcbEventLogStorage_Postgres.makeStorage(
215
+ ~pool,
216
+ ~name="feed-log",
217
+ ~indexes=[],
218
+ ~partitionTag=DcbTag.Simple({key: "k"}),
219
+ ~opts,
220
+ )
221
+ let mk = i => stored("Fed", [{key: "id", value: "f"}], jsonObj([("i", JSON.Encode.int(i))]))
222
+ let _ = await ops.append([mk(0), mk(1), mk(2)])
223
+
224
+ let collected = ref(0)
225
+ let processed = await PgChangeFeed.drain(pool, ~logName="feed-log", ~subscriber="sub-1", ~handle=async evs => {
226
+ collected := collected.contents + evs->Array.length
227
+ })
228
+ expect(processed)->toBe(3)
229
+ expect(collected.contents)->toBe(3)
230
+
231
+ // A second drain from the saved checkpoint sees nothing new.
232
+ let again = await PgChangeFeed.drain(pool, ~logName="feed-log", ~subscriber="sub-1", ~handle=async _ => ())
233
+ expect(again)->toBe(0)
234
+ })
235
+ })
236
+ }
@@ -0,0 +1,244 @@
1
+ // Postgres-backed QueryDb storage — JSONB document tables, one per read model.
2
+ //
3
+ // Implements the clean QueryDb_Adapter.storageMaker (the shape
4
+ // QueryDbStorage_DynamoDb implements): no in-process bus. The reventless-local
5
+ // bridge that publishes state-changes to live GraphQL subscriptions wraps this
6
+ // at integration time (Phase E), the way QueryDbStorage_Sqlite wraps its data
7
+ // access today.
8
+ //
9
+ // Table `qdb_<name>`: partition_key text, sub_key text, item jsonb, expires_at
10
+ // double precision (unix epoch seconds; null = never), PRIMARY KEY
11
+ // (partition_key, sub_key). `item jsonb` returns already parsed. TTL is lazy —
12
+ // every read carries `(expires_at IS NULL OR expires_at > epoch_now)`.
13
+ //
14
+ // GSI: each declared indexConfig becomes an expression index on `item->>'field'`
15
+ // (composite keys concatenated with the configured separator). projectionType is
16
+ // irrelevant in a relational store (every column is on the same row).
17
+
18
+ open ReventlessCore
19
+ open Reventless.ReadModel
20
+
21
+ let tableName = (name: string): string => "qdb_" ++ name->String.replaceAll("-", "_")
22
+
23
+ // Collapse anything outside [A-Za-z0-9_] to _ — predictable, injection-safe idents.
24
+ let sanitizeIdent = (s: string): string =>
25
+ s->String.replaceRegExp(%re("/[^A-Za-z0-9_]/g"), "_")
26
+
27
+ let notExpiredClause = "(expires_at IS NULL OR expires_at > extract(epoch from now()))"
28
+
29
+ // JSONB text extraction for a single top-level field.
30
+ let jsonField = (field: string): string =>
31
+ `(item->>'${field->String.replaceAll("'", "''")}')`
32
+
33
+ let compositeExpr = (fields: array<string>, sep: string): string => {
34
+ let escapedSep = sep->String.replaceAll("'", "''")
35
+ fields->Array.map(jsonField)->Array.join(` || '${escapedSep}' || `)
36
+ }
37
+
38
+ let indexExpressions = (idx: indexConfig): (string, option<string>) => {
39
+ let pkExpr = switch idx.pkFields {
40
+ | Some(fields) if fields->Array.length > 0 => compositeExpr(fields, idx.pkSep->Option.getOr("/"))
41
+ | _ =>
42
+ switch idx.idField {
43
+ | Some(field) => jsonField(field)
44
+ | None => jsonField(idx.index)
45
+ }
46
+ }
47
+ let skExpr = switch idx.skFields {
48
+ | Some(fields) if fields->Array.length > 0 =>
49
+ Some(compositeExpr(fields, idx.skSep->Option.getOr("/")))
50
+ | _ =>
51
+ switch idx.subIdField {
52
+ | Some(field) => Some(jsonField(field))
53
+ | None => None
54
+ }
55
+ }
56
+ (pkExpr, skExpr)
57
+ }
58
+
59
+ let ensureTable = async (~pool, ~table) => {
60
+ await pool->PgDriver.exec(
61
+ `CREATE TABLE IF NOT EXISTS ${table} (partition_key text NOT NULL, sub_key text NOT NULL DEFAULT '', item jsonb NOT NULL, expires_at double precision, PRIMARY KEY (partition_key, sub_key))`,
62
+ )
63
+ }
64
+
65
+ let ensureIndexes = async (~pool, ~table, ~indexes: array<indexConfig>) => {
66
+ for i in 0 to indexes->Array.length - 1 {
67
+ let idx = indexes->Array.getUnsafe(i)
68
+ let (pkExpr, skExprOpt) = indexExpressions(idx)
69
+ let indexName = `idx_${table}_${sanitizeIdent(idx.index)}`
70
+ let cols = switch skExprOpt {
71
+ | Some(skExpr) => `${pkExpr}, ${skExpr}`
72
+ | None => pkExpr
73
+ }
74
+ await pool->PgDriver.exec(`CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} (${cols})`)
75
+ }
76
+ }
77
+
78
+ let decodeItem = (row: dict<JSON.t>): JSON.t =>
79
+ row->Dict.get("item")->Option.getOr(JSON.Encode.null)
80
+
81
+ let computeSubKey = (item: JSON.t, subIdField: option<string>): string =>
82
+ switch subIdField {
83
+ | None => ""
84
+ | Some(field) =>
85
+ switch item->JSON.Decode.object->Option.flatMap(o => o->Dict.get(field)) {
86
+ | Some(JSON.String(s)) => s
87
+ | _ => ""
88
+ }
89
+ }
90
+
91
+ let withId = (item: JSON.t, partitionKey: string): JSON.t =>
92
+ switch item->JSON.Decode.object {
93
+ | Some(obj) if obj->Dict.get("id")->Option.isNone =>
94
+ let copy = Dict.copy(obj)
95
+ copy->Dict.set("id", JSON.Encode.string(partitionKey))
96
+ JSON.Encode.object(copy)
97
+ | _ => item
98
+ }
99
+
100
+ let makeStorage = (
101
+ ~pool: PgDriver.pool,
102
+ ~name: string,
103
+ ~indexes: array<indexConfig>,
104
+ ~subIdField: option<string>,
105
+ ): QueryDb_Adapter.storage => {
106
+ let table = tableName(name)
107
+ // Schema setup is async but makeStorage is synchronous; gate every operation on
108
+ // this promise so the first access waits for table + index creation exactly
109
+ // once (subsequent awaits resolve immediately).
110
+ let ready = {
111
+ open Promise
112
+ ensureTable(~pool, ~table)->then(() => ensureIndexes(~pool, ~table, ~indexes))
113
+ }
114
+
115
+ let rowsFor = async (id: string): array<JSON.t> => {
116
+ await ready
117
+ (await pool->PgDriver.query(
118
+ `SELECT item FROM ${table} WHERE partition_key = $1 AND ${notExpiredClause} ORDER BY sub_key ASC`,
119
+ [JSON.Encode.string(id)],
120
+ ))->Array.map(decodeItem)
121
+ }
122
+
123
+ let saveOne = async (id: string, state: JSON.t, ttl: option<int>) => {
124
+ await ready
125
+ let subKey = computeSubKey(state, subIdField)
126
+ let expiresAt = switch ttl {
127
+ | Some(t) => JSON.Encode.int(t)
128
+ | None => JSON.Encode.null
129
+ }
130
+ let _ = await pool->PgDriver.query(
131
+ `INSERT INTO ${table}(partition_key, sub_key, item, expires_at)
132
+ VALUES ($1, $2, $3::jsonb, $4)
133
+ ON CONFLICT (partition_key, sub_key)
134
+ DO UPDATE SET item = EXCLUDED.item, expires_at = EXCLUDED.expires_at`,
135
+ [
136
+ JSON.Encode.string(id),
137
+ JSON.Encode.string(subKey),
138
+ JSON.Encode.string(JSON.stringify(state)),
139
+ expiresAt,
140
+ ],
141
+ )
142
+ }
143
+
144
+ let load: QueryDb.load<string, JSON.t> = async id => Ok(await rowsFor(id))
145
+
146
+ let loadStream: QueryDb.loadStream<string, JSON.t> =
147
+ id =>
148
+ Effect.promise(() => rowsFor(id))
149
+ ->Stream.fromEffect
150
+ ->Stream.flatMap(arr => Stream.fromIterable(arr))
151
+
152
+ let save: QueryDb.save<string, JSON.t> = async (id, state, _saveMode, ttl) => {
153
+ await saveOne(id, state, ttl)
154
+ Ok()
155
+ }
156
+
157
+ let saveBatch: QueryDb.saveBatch<string, JSON.t> = async batch => {
158
+ // pg has no synchronous multi-statement transaction helper here; the upserts
159
+ // are independent (distinct keys) so a sequential apply is equivalent.
160
+ for i in 0 to batch->Array.length - 1 {
161
+ let (id, state, ttl) = batch->Array.getUnsafe(i)
162
+ await saveOne(id, state, ttl)
163
+ }
164
+ Ok()
165
+ }
166
+
167
+ // Mirror DynamoDB's `ADD #field :inc`: read the counter on the partition item
168
+ // (counters are single-state, sub_key=""), add inc, upsert, return NEW total.
169
+ let count: QueryDb.count<string> = async (id, fieldName, inc) => {
170
+ await ready
171
+ let existing = (await rowsFor(id))->Array.get(0)->Option.flatMap(JSON.Decode.object)
172
+ let current =
173
+ existing
174
+ ->Option.flatMap(o => o->Dict.get(fieldName))
175
+ ->Option.flatMap(JSON.Decode.float)
176
+ ->Option.mapOr(0, Float.toInt)
177
+ let next = current + inc
178
+ let obj = switch existing {
179
+ | Some(o) => Dict.copy(o)
180
+ | None => Dict.make()
181
+ }
182
+ obj->Dict.set("id", JSON.Encode.string(id))
183
+ obj->Dict.set(fieldName, JSON.Encode.int(next))
184
+ await saveOne(id, JSON.Encode.object(obj), None)
185
+ Ok(next)
186
+ }
187
+
188
+ let delete: QueryDb.delete<string> = async (id, subIdOpt) => {
189
+ await ready
190
+ let _ = switch subIdOpt {
191
+ | None =>
192
+ await pool->PgDriver.query(
193
+ `DELETE FROM ${table} WHERE partition_key = $1`,
194
+ [JSON.Encode.string(id)],
195
+ )
196
+ | Some((_, subValue)) =>
197
+ await pool->PgDriver.query(
198
+ `DELETE FROM ${table} WHERE partition_key = $1 AND sub_key = $2`,
199
+ [JSON.Encode.string(id), JSON.Encode.string(subValue)],
200
+ )
201
+ }
202
+ Ok()
203
+ }
204
+
205
+ let deleteBatch: QueryDb.deleteBatch<string> = async ids => {
206
+ for i in 0 to ids->Array.length - 1 {
207
+ let (id, subIdOpt) = ids->Array.getUnsafe(i)
208
+ let _ = await delete(id, subIdOpt)
209
+ }
210
+ Ok()
211
+ }
212
+
213
+ let ops: QueryDb_Adapter.operations = {
214
+ load,
215
+ loadStream,
216
+ save,
217
+ saveBatch,
218
+ count,
219
+ delete,
220
+ deleteBatch,
221
+ }
222
+
223
+ {
224
+ QueryDb_Adapter.resources: [],
225
+ dataSourceName: ""->Pulumi.Output.make,
226
+ operations: Pulumi.Output.make(ops),
227
+ }
228
+ }
229
+
230
+ // Standalone/deploy storage: inject the pool via a provider module.
231
+ module Make = (P: {let pool: PgDriver.pool}) => {
232
+ type api = unit
233
+ type role = unit
234
+
235
+ let make: QueryDb_Adapter.storageMaker<unit, unit> = (
236
+ ~name,
237
+ ~indexes,
238
+ ~subIdField=?,
239
+ ~ttl as _=?,
240
+ ~api as _,
241
+ ~apiRole as _,
242
+ ~opts as _,
243
+ ) => makeStorage(~pool=P.pool, ~name, ~indexes, ~subIdField)
244
+ }
@@ -0,0 +1,127 @@
1
+ // Postgres QueryEngine — compiles the QueryEngine query/scan AST to SQL over the
2
+ // `qdb_<name>` JSONB tables (QueryDbStorage_Postgres). The reventless-local
3
+ // LocalQueryEngine reads from in-process bus registries and ignores filters; this
4
+ // one pushes filters down to SQL, the way a production engine must.
5
+
6
+ open ReventlessCore
7
+ open Reventless
8
+
9
+ let valueToText = (v: QueryEngine.value): string =>
10
+ switch v {
11
+ | QueryEngine.String(s) => s
12
+ | QueryEngine.Int(i) => i->Int.toString
13
+ | QueryEngine.Bool(b) => b ? "true" : "false"
14
+ }
15
+
16
+ let isNumeric = (v: QueryEngine.value): bool =>
17
+ switch v {
18
+ | QueryEngine.Int(_) => true
19
+ | _ => false
20
+ }
21
+
22
+ type paramBuilder = {mutable params: array<JSON.t>}
23
+ let param = (b: paramBuilder, v: JSON.t): string => {
24
+ b.params->Array.push(v)
25
+ "$" ++ Int.toString(b.params->Array.length)
26
+ }
27
+
28
+ let col = (field: string): string => `(item->>'${field->String.replaceAll("'", "''")}')`
29
+
30
+ // One Filter.config → SQL predicate, appending any bound params.
31
+ let filterSql = (b: paramBuilder, field: string, cmp: QueryEngine.Filter.comparator, v: QueryEngine.value): string => {
32
+ let c = col(field)
33
+ let bind = () => b->param(JSON.Encode.string(valueToText(v)))
34
+ let cmpSql = op =>
35
+ isNumeric(v) ? `${c}::numeric ${op} ${bind()}::numeric` : `${c} ${op} ${bind()}`
36
+ switch cmp {
37
+ | QueryEngine.Filter.Equal => cmpSql("=")
38
+ | Unequal => cmpSql("<>")
39
+ | LessOrEqual => cmpSql("<=")
40
+ | Less => cmpSql("<")
41
+ | GreaterOrEqual => cmpSql(">=")
42
+ | Greater => cmpSql(">")
43
+ | Exists => `(item ? '${field->String.replaceAll("'", "''")}')`
44
+ | NotExists => `NOT (item ? '${field->String.replaceAll("'", "''")}')`
45
+ | Contains => `strpos(${c}, ${bind()}) > 0`
46
+ | NotContains => `(${c} IS NULL OR strpos(${c}, ${bind()}) = 0)`
47
+ | BeginsWith => `starts_with(${c}, ${bind()})`
48
+ }
49
+ }
50
+
51
+ let subIdSql = (b: paramBuilder, field: string, cmp: QueryEngine.SubId.comparator, v: QueryEngine.value): string => {
52
+ let c = col(field)
53
+ let bind = () => b->param(JSON.Encode.string(valueToText(v)))
54
+ let cmpSql = op => isNumeric(v) ? `${c}::numeric ${op} ${bind()}::numeric` : `${c} ${op} ${bind()}`
55
+ switch cmp {
56
+ | QueryEngine.SubId.Equal => cmpSql("=")
57
+ | Unequal => cmpSql("<>")
58
+ | LessOrEqual => cmpSql("<=")
59
+ | Less => cmpSql("<")
60
+ | GreaterOrEqual => cmpSql(">=")
61
+ | Greater => cmpSql(">")
62
+ | BeginsWith => `starts_with(${c}, ${bind()})`
63
+ }
64
+ }
65
+
66
+ let notExpired = QueryDbStorage_Postgres.notExpiredClause
67
+
68
+ // Add `id` (from partition_key) when absent — matches allRows/scan in the SQLite
69
+ // engine; partition-key queries return items as stored.
70
+ let decodeRow = (~withId, row: dict<JSON.t>): JSON.t => {
71
+ let item = QueryDbStorage_Postgres.decodeItem(row)
72
+ if withId {
73
+ let pk = switch row->Dict.get("partition_key") {
74
+ | Some(JSON.String(s)) => s
75
+ | _ => ""
76
+ }
77
+ QueryDbStorage_Postgres.withId(item, pk)
78
+ } else {
79
+ item
80
+ }
81
+ }
82
+
83
+ module Make = (P: {let pool: PgDriver.pool}) => {
84
+ let query = async (
85
+ ~readModelName,
86
+ ~key=?,
87
+ ~id: QueryEngine.value,
88
+ ~subIdConfig=?,
89
+ ~filterConfigs=?,
90
+ ~ascending=?,
91
+ ~limit=?,
92
+ ): array<JSON.t> => {
93
+ let table = QueryDbStorage_Postgres.tableName(readModelName)
94
+ let keyStr = switch key {
95
+ | Some(k) => k
96
+ | None => valueToText(id)
97
+ }
98
+ let b = {params: []}
99
+ let where = [`partition_key = ${b->param(JSON.Encode.string(keyStr))}`, notExpired]
100
+ switch subIdConfig {
101
+ | Some((field, cmp, v)) => where->Array.push(subIdSql(b, field, cmp, v))
102
+ | None => ()
103
+ }
104
+ filterConfigs
105
+ ->Option.getOr([])
106
+ ->Array.forEach(((field, cmp, v)) => where->Array.push(filterSql(b, field, cmp, v)))
107
+ let dir = ascending->Option.getOr(true) ? "ASC" : "DESC"
108
+ let limitClause = switch limit {
109
+ | Some(n) => ` LIMIT ${b->param(JSON.Encode.int(n))}`
110
+ | None => ""
111
+ }
112
+ let sql = `SELECT partition_key, item FROM ${table} WHERE ${where->Array.join(" AND ")} ORDER BY sub_key ${dir}${limitClause}`
113
+ (await P.pool->PgDriver.query(sql, b.params))->Array.map(decodeRow(~withId=false, ...))
114
+ }
115
+
116
+ let scan = async (~readModelName, ~filterConfigs, ~limit): array<JSON.t> => {
117
+ let table = QueryDbStorage_Postgres.tableName(readModelName)
118
+ let b = {params: []}
119
+ let where = [notExpired]
120
+ filterConfigs->Array.forEach(((field, cmp, v)) => where->Array.push(filterSql(b, field, cmp, v)))
121
+ let sql = `SELECT partition_key, item FROM ${table} WHERE ${where->Array.join(" AND ")} ORDER BY partition_key, sub_key LIMIT ${b->param(JSON.Encode.int(limit))}`
122
+ (await P.pool->PgDriver.query(sql, b.params))->Array.map(decodeRow(~withId=true, ...))
123
+ }
124
+
125
+ let make: QueryDb_Adapter.queryEngineMaker = _allQueryDbs =>
126
+ Pulumi.Output.make({QueryEngine.scan, query})
127
+ }
Binary file
Binary file
Binary file
@@ -0,0 +1,9 @@
1
+ randjbuildsystem
2
+ DcbEventLogStorage_Postgres
3
+ EventLogStorage_Postgres
4
+ PgChangeFeed
5
+ PgDriver
6
+ PgSchema
7
+ PostgresIntegrationTest
8
+ QueryDbStorage_Postgres
9
+ QueryEnginePostgres