@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.
- package/CHANGELOG.md +11 -0
- package/LICENSE +202 -0
- package/__mocks__/emptyModule.js +1 -0
- package/lib/bs/.compiler.log +2 -0
- package/lib/bs/ReventlessPostgres.cmi +0 -0
- package/lib/bs/ReventlessPostgres.cmj +0 -0
- package/lib/bs/ReventlessPostgres.cmt +0 -0
- package/lib/bs/ReventlessPostgres.mlmap +9 -0
- package/lib/bs/build.ninja +0 -0
- package/lib/bs/compiler-info.json +8 -0
- package/lib/bs/src/DcbEventLogStorage_Postgres-ReventlessPostgres.cmi +0 -0
- package/lib/bs/src/DcbEventLogStorage_Postgres-ReventlessPostgres.cmj +0 -0
- package/lib/bs/src/DcbEventLogStorage_Postgres-ReventlessPostgres.cmt +0 -0
- package/lib/bs/src/DcbEventLogStorage_Postgres.ast +0 -0
- package/lib/bs/src/DcbEventLogStorage_Postgres.res +352 -0
- package/lib/bs/src/DcbEventLogStorage_Postgres.res.mjs +385 -0
- package/lib/bs/src/EventLogStorage_Postgres-ReventlessPostgres.cmi +0 -0
- package/lib/bs/src/EventLogStorage_Postgres-ReventlessPostgres.cmj +0 -0
- package/lib/bs/src/EventLogStorage_Postgres-ReventlessPostgres.cmt +0 -0
- package/lib/bs/src/EventLogStorage_Postgres.ast +0 -0
- package/lib/bs/src/EventLogStorage_Postgres.res +176 -0
- package/lib/bs/src/EventLogStorage_Postgres.res.mjs +207 -0
- package/lib/bs/src/PgChangeFeed-ReventlessPostgres.cmi +0 -0
- package/lib/bs/src/PgChangeFeed-ReventlessPostgres.cmj +0 -0
- package/lib/bs/src/PgChangeFeed-ReventlessPostgres.cmt +0 -0
- package/lib/bs/src/PgChangeFeed.ast +0 -0
- package/lib/bs/src/PgChangeFeed.res +145 -0
- package/lib/bs/src/PgChangeFeed.res.mjs +90 -0
- package/lib/bs/src/PgDriver-ReventlessPostgres.cmi +0 -0
- package/lib/bs/src/PgDriver-ReventlessPostgres.cmj +0 -0
- package/lib/bs/src/PgDriver-ReventlessPostgres.cmt +0 -0
- package/lib/bs/src/PgDriver-ReventlessPostgres.cmti +0 -0
- package/lib/bs/src/PgDriver.ast +0 -0
- package/lib/bs/src/PgDriver.iast +0 -0
- package/lib/bs/src/PgDriver.res +111 -0
- package/lib/bs/src/PgDriver.res.mjs +85 -0
- package/lib/bs/src/PgDriver.resi +68 -0
- package/lib/bs/src/PgSchema-ReventlessPostgres.cmi +0 -0
- package/lib/bs/src/PgSchema-ReventlessPostgres.cmj +0 -0
- package/lib/bs/src/PgSchema-ReventlessPostgres.cmt +0 -0
- package/lib/bs/src/PgSchema.ast +0 -0
- package/lib/bs/src/PgSchema.res +256 -0
- package/lib/bs/src/PgSchema.res.mjs +46 -0
- package/lib/bs/src/QueryDbStorage_Postgres-ReventlessPostgres.cmi +0 -0
- package/lib/bs/src/QueryDbStorage_Postgres-ReventlessPostgres.cmj +0 -0
- package/lib/bs/src/QueryDbStorage_Postgres-ReventlessPostgres.cmt +0 -0
- package/lib/bs/src/QueryDbStorage_Postgres.ast +0 -0
- package/lib/bs/src/QueryDbStorage_Postgres.res +244 -0
- package/lib/bs/src/QueryDbStorage_Postgres.res.mjs +225 -0
- package/lib/bs/src/QueryEnginePostgres-ReventlessPostgres.cmi +0 -0
- package/lib/bs/src/QueryEnginePostgres-ReventlessPostgres.cmj +0 -0
- package/lib/bs/src/QueryEnginePostgres-ReventlessPostgres.cmt +0 -0
- package/lib/bs/src/QueryEnginePostgres.ast +0 -0
- package/lib/bs/src/QueryEnginePostgres.res +127 -0
- package/lib/bs/src/QueryEnginePostgres.res.mjs +172 -0
- package/lib/bs/tests/PostgresIntegrationTest-ReventlessPostgres.cmi +0 -0
- package/lib/bs/tests/PostgresIntegrationTest-ReventlessPostgres.cmj +0 -0
- package/lib/bs/tests/PostgresIntegrationTest-ReventlessPostgres.cmt +0 -0
- package/lib/bs/tests/PostgresIntegrationTest.ast +0 -0
- package/lib/bs/tests/PostgresIntegrationTest.res +236 -0
- package/lib/bs/tests/PostgresIntegrationTest.res.mjs +328 -0
- package/lib/ocaml/.compiler.log +2 -0
- package/lib/ocaml/DcbEventLogStorage_Postgres-ReventlessPostgres.cmi +0 -0
- package/lib/ocaml/DcbEventLogStorage_Postgres-ReventlessPostgres.cmj +0 -0
- package/lib/ocaml/DcbEventLogStorage_Postgres-ReventlessPostgres.cmt +0 -0
- package/lib/ocaml/DcbEventLogStorage_Postgres.ast +0 -0
- package/lib/ocaml/DcbEventLogStorage_Postgres.res +352 -0
- package/lib/ocaml/EventLogStorage_Postgres-ReventlessPostgres.cmi +0 -0
- package/lib/ocaml/EventLogStorage_Postgres-ReventlessPostgres.cmj +0 -0
- package/lib/ocaml/EventLogStorage_Postgres-ReventlessPostgres.cmt +0 -0
- package/lib/ocaml/EventLogStorage_Postgres.ast +0 -0
- package/lib/ocaml/EventLogStorage_Postgres.res +176 -0
- package/lib/ocaml/PgChangeFeed-ReventlessPostgres.cmi +0 -0
- package/lib/ocaml/PgChangeFeed-ReventlessPostgres.cmj +0 -0
- package/lib/ocaml/PgChangeFeed-ReventlessPostgres.cmt +0 -0
- package/lib/ocaml/PgChangeFeed.ast +0 -0
- package/lib/ocaml/PgChangeFeed.res +145 -0
- package/lib/ocaml/PgDriver-ReventlessPostgres.cmi +0 -0
- package/lib/ocaml/PgDriver-ReventlessPostgres.cmj +0 -0
- package/lib/ocaml/PgDriver-ReventlessPostgres.cmt +0 -0
- package/lib/ocaml/PgDriver-ReventlessPostgres.cmti +0 -0
- package/lib/ocaml/PgDriver.ast +0 -0
- package/lib/ocaml/PgDriver.iast +0 -0
- package/lib/ocaml/PgDriver.res +111 -0
- package/lib/ocaml/PgDriver.resi +68 -0
- package/lib/ocaml/PgSchema-ReventlessPostgres.cmi +0 -0
- package/lib/ocaml/PgSchema-ReventlessPostgres.cmj +0 -0
- package/lib/ocaml/PgSchema-ReventlessPostgres.cmt +0 -0
- package/lib/ocaml/PgSchema.ast +0 -0
- package/lib/ocaml/PgSchema.res +256 -0
- package/lib/ocaml/PostgresIntegrationTest-ReventlessPostgres.cmi +0 -0
- package/lib/ocaml/PostgresIntegrationTest-ReventlessPostgres.cmj +0 -0
- package/lib/ocaml/PostgresIntegrationTest-ReventlessPostgres.cmt +0 -0
- package/lib/ocaml/PostgresIntegrationTest.ast +0 -0
- package/lib/ocaml/PostgresIntegrationTest.res +236 -0
- package/lib/ocaml/QueryDbStorage_Postgres-ReventlessPostgres.cmi +0 -0
- package/lib/ocaml/QueryDbStorage_Postgres-ReventlessPostgres.cmj +0 -0
- package/lib/ocaml/QueryDbStorage_Postgres-ReventlessPostgres.cmt +0 -0
- package/lib/ocaml/QueryDbStorage_Postgres.ast +0 -0
- package/lib/ocaml/QueryDbStorage_Postgres.res +244 -0
- package/lib/ocaml/QueryEnginePostgres-ReventlessPostgres.cmi +0 -0
- package/lib/ocaml/QueryEnginePostgres-ReventlessPostgres.cmj +0 -0
- package/lib/ocaml/QueryEnginePostgres-ReventlessPostgres.cmt +0 -0
- package/lib/ocaml/QueryEnginePostgres.ast +0 -0
- package/lib/ocaml/QueryEnginePostgres.res +127 -0
- package/lib/ocaml/ReventlessPostgres.cmi +0 -0
- package/lib/ocaml/ReventlessPostgres.cmj +0 -0
- package/lib/ocaml/ReventlessPostgres.cmt +0 -0
- package/lib/ocaml/ReventlessPostgres.mlmap +9 -0
- package/package.json +56 -0
- package/rescript.json +36 -0
- package/src/DcbEventLogStorage_Postgres.res +352 -0
- package/src/DcbEventLogStorage_Postgres.res.mjs +385 -0
- package/src/EventLogStorage_Postgres.res +176 -0
- package/src/EventLogStorage_Postgres.res.mjs +207 -0
- package/src/PgChangeFeed.res +145 -0
- package/src/PgChangeFeed.res.mjs +90 -0
- package/src/PgDriver.res +111 -0
- package/src/PgDriver.res.mjs +85 -0
- package/src/PgDriver.resi +68 -0
- package/src/PgSchema.res +256 -0
- package/src/PgSchema.res.mjs +46 -0
- package/src/QueryDbStorage_Postgres.res +244 -0
- package/src/QueryDbStorage_Postgres.res.mjs +225 -0
- package/src/QueryEnginePostgres.res +127 -0
- package/src/QueryEnginePostgres.res.mjs +172 -0
- package/tests/PostgresIntegrationTest.res +236 -0
- 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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|