@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
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
|
+
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
5
|
+
import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
|
|
6
|
+
import * as PgDriver$ReventlessPostgres from "../src/PgDriver.res.mjs";
|
|
7
|
+
import * as PgSchema$ReventlessPostgres from "../src/PgSchema.res.mjs";
|
|
8
|
+
import * as PgChangeFeed$ReventlessPostgres from "../src/PgChangeFeed.res.mjs";
|
|
9
|
+
import * as EventLogStorage_Postgres$ReventlessPostgres from "../src/EventLogStorage_Postgres.res.mjs";
|
|
10
|
+
import * as DcbEventLogStorage_Postgres$ReventlessPostgres from "../src/DcbEventLogStorage_Postgres.res.mjs";
|
|
11
|
+
|
|
12
|
+
let opts = {};
|
|
13
|
+
|
|
14
|
+
function jsonObj(pairs) {
|
|
15
|
+
return Object.fromEntries(pairs);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stored(eventType, tags, data) {
|
|
19
|
+
return {
|
|
20
|
+
eventType: eventType,
|
|
21
|
+
data: data,
|
|
22
|
+
tags: tags,
|
|
23
|
+
meta: Message$ReventlessCore.generateMeta("pg-test", undefined, undefined, undefined, undefined, undefined, undefined, undefined)
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function evJson(msgId, v) {
|
|
28
|
+
return Object.fromEntries([
|
|
29
|
+
[
|
|
30
|
+
"msgId",
|
|
31
|
+
msgId
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
"v",
|
|
35
|
+
v
|
|
36
|
+
]
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let url = process.env["PG_URL"];
|
|
41
|
+
|
|
42
|
+
if (url !== undefined) {
|
|
43
|
+
let pool = PgDriver$ReventlessPostgres.makePool({
|
|
44
|
+
connectionString: url
|
|
45
|
+
});
|
|
46
|
+
globalThis.beforeAll(async () => {
|
|
47
|
+
await PgSchema$ReventlessPostgres.ensureSchema(pool);
|
|
48
|
+
return await PgSchema$ReventlessPostgres.truncateAll(pool);
|
|
49
|
+
});
|
|
50
|
+
globalThis.afterAll(() => {
|
|
51
|
+
PgDriver$ReventlessPostgres.endPool(pool);
|
|
52
|
+
});
|
|
53
|
+
globalThis.describe("classic OCC event log", () => {
|
|
54
|
+
globalThis.test("append, replay, and conflict on wrong seq", async () => {
|
|
55
|
+
let match = EventLogStorage_Postgres$ReventlessPostgres.makeStorage(pool, "ct-orders", opts, undefined);
|
|
56
|
+
let ops = match[1];
|
|
57
|
+
let r0 = await ops.append(0, "agg-1", [
|
|
58
|
+
evJson("m0", 1),
|
|
59
|
+
evJson("m1", 2)
|
|
60
|
+
]);
|
|
61
|
+
if (r0.TAG === "Ok") {
|
|
62
|
+
globalThis.expect(true).toBe(true);
|
|
63
|
+
} else {
|
|
64
|
+
globalThis.expect("append").toBe("ok");
|
|
65
|
+
}
|
|
66
|
+
let rStale = await ops.append(0, "agg-1", [evJson("m2", 3)]);
|
|
67
|
+
if (rStale.TAG === "Ok") {
|
|
68
|
+
globalThis.expect("stale").toBe("conflict");
|
|
69
|
+
} else {
|
|
70
|
+
let tmp = rStale._0;
|
|
71
|
+
if (typeof tmp !== "object") {
|
|
72
|
+
globalThis.expect(true).toBe(true);
|
|
73
|
+
} else {
|
|
74
|
+
globalThis.expect("stale").toBe("conflict");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
let rGap = await ops.append(5, "agg-1", [evJson("m3", 4)]);
|
|
78
|
+
if (rGap.TAG === "Ok") {
|
|
79
|
+
globalThis.expect("gap").toBe("conflict");
|
|
80
|
+
} else {
|
|
81
|
+
let tmp$1 = rGap._0;
|
|
82
|
+
if (typeof tmp$1 !== "object") {
|
|
83
|
+
globalThis.expect(true).toBe(true);
|
|
84
|
+
} else {
|
|
85
|
+
globalThis.expect("gap").toBe("conflict");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
let rNext = await ops.append(2, "agg-1", [evJson("m4", 5)]);
|
|
89
|
+
if (rNext.TAG === "Ok") {
|
|
90
|
+
globalThis.expect(true).toBe(true);
|
|
91
|
+
} else {
|
|
92
|
+
globalThis.expect("next").toBe("ok");
|
|
93
|
+
}
|
|
94
|
+
let evs = await ops.replay("agg-1");
|
|
95
|
+
globalThis.expect(evs.length).toBe(3);
|
|
96
|
+
let rEmpty = await ops.append(3, "agg-1", []);
|
|
97
|
+
if (rEmpty.TAG === "Ok") {
|
|
98
|
+
globalThis.expect(true).toBe(true);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
globalThis.expect("empty").toBe("ok");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
globalThis.describe("DCB event log", () => {
|
|
105
|
+
let makeLog = name => DcbEventLogStorage_Postgres$ReventlessPostgres.makeStorage(pool, name, [], {
|
|
106
|
+
TAG: "Simple",
|
|
107
|
+
_0: {
|
|
108
|
+
key: "k"
|
|
109
|
+
}
|
|
110
|
+
}, undefined, opts, undefined, undefined);
|
|
111
|
+
globalThis.test("append + read round-trips events with tags", async () => {
|
|
112
|
+
let match = makeLog("dcb-rt");
|
|
113
|
+
let ops = match[1];
|
|
114
|
+
let e1 = stored("ItemAdded", [{
|
|
115
|
+
key: "itemId",
|
|
116
|
+
value: "x1"
|
|
117
|
+
}], Object.fromEntries([[
|
|
118
|
+
"n",
|
|
119
|
+
"w"
|
|
120
|
+
]]));
|
|
121
|
+
let r = await ops.append([e1], undefined);
|
|
122
|
+
if (r.TAG === "Ok") {
|
|
123
|
+
globalThis.expect(true).toBe(true);
|
|
124
|
+
} else {
|
|
125
|
+
globalThis.expect(r._0).toBe("ok");
|
|
126
|
+
}
|
|
127
|
+
let read = await ops.read([], undefined);
|
|
128
|
+
globalThis.expect(read.events.length).toBe(1);
|
|
129
|
+
let first = read.events[0];
|
|
130
|
+
globalThis.expect(first.eventType).toBe("ItemAdded");
|
|
131
|
+
globalThis.expect(first.tags[0].key).toBe("itemId");
|
|
132
|
+
globalThis.expect(first.tags[0].value).toBe("x1");
|
|
133
|
+
});
|
|
134
|
+
globalThis.test("conditional append conflicts when the boundary already has an event", async () => {
|
|
135
|
+
let match = makeLog("dcb-cond");
|
|
136
|
+
let ops = match[1];
|
|
137
|
+
let mk = who => stored("Reserved", [{
|
|
138
|
+
key: "seatId",
|
|
139
|
+
value: "s1"
|
|
140
|
+
}], Object.fromEntries([[
|
|
141
|
+
"who",
|
|
142
|
+
who
|
|
143
|
+
]]));
|
|
144
|
+
let r1 = await ops.append([mk("a")], undefined);
|
|
145
|
+
if (r1.TAG === "Ok") {
|
|
146
|
+
globalThis.expect(true).toBe(true);
|
|
147
|
+
} else {
|
|
148
|
+
globalThis.expect("first").toBe("ok");
|
|
149
|
+
}
|
|
150
|
+
let cond_query = [{
|
|
151
|
+
eventTypes: ["Reserved"],
|
|
152
|
+
tags: [{
|
|
153
|
+
key: "seatId",
|
|
154
|
+
value: "s1"
|
|
155
|
+
}]
|
|
156
|
+
}];
|
|
157
|
+
let cond = {
|
|
158
|
+
query: cond_query
|
|
159
|
+
};
|
|
160
|
+
let r2 = await ops.append([mk("b")], cond);
|
|
161
|
+
if (r2.TAG === "Ok") {
|
|
162
|
+
globalThis.expect("second").toBe("conflict");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
let tmp = r2._0;
|
|
166
|
+
if (typeof tmp !== "object") {
|
|
167
|
+
globalThis.expect(true).toBe(true);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
globalThis.expect("second").toBe("conflict");
|
|
171
|
+
});
|
|
172
|
+
let writeSkew = (strategy, logName) => {
|
|
173
|
+
globalThis.test(`write-skew: exactly one of two concurrent appends wins (` + logName + `)`, async () => {
|
|
174
|
+
let match = DcbEventLogStorage_Postgres$ReventlessPostgres.makeStorage(pool, logName, [], {
|
|
175
|
+
TAG: "Simple",
|
|
176
|
+
_0: {
|
|
177
|
+
key: "k"
|
|
178
|
+
}
|
|
179
|
+
}, undefined, opts, strategy, undefined);
|
|
180
|
+
let ops = match[1];
|
|
181
|
+
let cond_query = [{
|
|
182
|
+
eventTypes: ["Reserved"],
|
|
183
|
+
tags: [{
|
|
184
|
+
key: "seatId",
|
|
185
|
+
value: "hot"
|
|
186
|
+
}]
|
|
187
|
+
}];
|
|
188
|
+
let cond = {
|
|
189
|
+
query: cond_query
|
|
190
|
+
};
|
|
191
|
+
let mk = who => stored("Reserved", [{
|
|
192
|
+
key: "seatId",
|
|
193
|
+
value: "hot"
|
|
194
|
+
}], Object.fromEntries([[
|
|
195
|
+
"who",
|
|
196
|
+
who
|
|
197
|
+
]]));
|
|
198
|
+
let p1 = ops.append([mk("a")], cond);
|
|
199
|
+
let p2 = ops.append([mk("b")], cond);
|
|
200
|
+
let r1 = await p1;
|
|
201
|
+
let r2 = await p2;
|
|
202
|
+
let isOk = r => r.TAG === "Ok";
|
|
203
|
+
let isConflict = r => Primitive_object.equal(r, {
|
|
204
|
+
TAG: "Error",
|
|
205
|
+
_0: "Conflict"
|
|
206
|
+
});
|
|
207
|
+
let oks = [
|
|
208
|
+
r1,
|
|
209
|
+
r2
|
|
210
|
+
].filter(isOk).length;
|
|
211
|
+
let conflicts = [
|
|
212
|
+
r1,
|
|
213
|
+
r2
|
|
214
|
+
].filter(isConflict).length;
|
|
215
|
+
globalThis.expect(oks).toBe(1);
|
|
216
|
+
globalThis.expect(conflicts).toBe(1);
|
|
217
|
+
let read = await ops.read([{
|
|
218
|
+
tags: [{
|
|
219
|
+
key: "seatId",
|
|
220
|
+
value: "hot"
|
|
221
|
+
}]
|
|
222
|
+
}], undefined);
|
|
223
|
+
globalThis.expect(read.events.length).toBe(1);
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
writeSkew("AdvisoryLocks", "dcb-skew-advisory");
|
|
227
|
+
writeSkew("RowLocks", "dcb-skew-rows");
|
|
228
|
+
globalThis.test("hot tag: N concurrent conditional appends, exactly one wins", async () => {
|
|
229
|
+
let match = makeLog("dcb-hot");
|
|
230
|
+
let ops = match[1];
|
|
231
|
+
let cond_query = [{
|
|
232
|
+
eventTypes: ["Claimed"],
|
|
233
|
+
tags: [{
|
|
234
|
+
key: "prizeId",
|
|
235
|
+
value: "p1"
|
|
236
|
+
}]
|
|
237
|
+
}];
|
|
238
|
+
let cond = {
|
|
239
|
+
query: cond_query
|
|
240
|
+
};
|
|
241
|
+
let promises = Stdlib_Array.make(12, 0).map((param, i) => ops.append([stored("Claimed", [{
|
|
242
|
+
key: "prizeId",
|
|
243
|
+
value: "p1"
|
|
244
|
+
}], Object.fromEntries([[
|
|
245
|
+
"who",
|
|
246
|
+
i
|
|
247
|
+
]]))], cond));
|
|
248
|
+
let results = await Promise.all(promises);
|
|
249
|
+
let oks = results.filter(r => r.TAG === "Ok").length;
|
|
250
|
+
globalThis.expect(oks).toBe(1);
|
|
251
|
+
let read = await ops.read([{
|
|
252
|
+
tags: [{
|
|
253
|
+
key: "prizeId",
|
|
254
|
+
value: "p1"
|
|
255
|
+
}]
|
|
256
|
+
}], undefined);
|
|
257
|
+
globalThis.expect(read.events.length).toBe(1);
|
|
258
|
+
});
|
|
259
|
+
globalThis.test("cursors are monotonic and usable as `after`", async () => {
|
|
260
|
+
let match = makeLog("dcb-cursor");
|
|
261
|
+
let ops = match[1];
|
|
262
|
+
let mk = i => stored("Tick", [{
|
|
263
|
+
key: "streamId",
|
|
264
|
+
value: "c1"
|
|
265
|
+
}], Object.fromEntries([[
|
|
266
|
+
"i",
|
|
267
|
+
i
|
|
268
|
+
]]));
|
|
269
|
+
await ops.append([mk(0)], undefined);
|
|
270
|
+
let pos = await ops.append([mk(1)], undefined);
|
|
271
|
+
let mid;
|
|
272
|
+
mid = pos.TAG === "Ok" ? pos._0 : "";
|
|
273
|
+
await ops.append([mk(2)], undefined);
|
|
274
|
+
let read = await ops.read([{
|
|
275
|
+
tags: [{
|
|
276
|
+
key: "streamId",
|
|
277
|
+
value: "c1"
|
|
278
|
+
}]
|
|
279
|
+
}], mid);
|
|
280
|
+
globalThis.expect(read.events.length).toBe(1);
|
|
281
|
+
globalThis.expect(read.events[0].eventType).toBe("Tick");
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
globalThis.describe("change feed", () => {
|
|
285
|
+
globalThis.test("drains all events and checkpoints", async () => {
|
|
286
|
+
let match = DcbEventLogStorage_Postgres$ReventlessPostgres.makeStorage(pool, "feed-log", [], {
|
|
287
|
+
TAG: "Simple",
|
|
288
|
+
_0: {
|
|
289
|
+
key: "k"
|
|
290
|
+
}
|
|
291
|
+
}, undefined, opts, undefined, undefined);
|
|
292
|
+
let mk = i => stored("Fed", [{
|
|
293
|
+
key: "id",
|
|
294
|
+
value: "f"
|
|
295
|
+
}], Object.fromEntries([[
|
|
296
|
+
"i",
|
|
297
|
+
i
|
|
298
|
+
]]));
|
|
299
|
+
await match[1].append([
|
|
300
|
+
mk(0),
|
|
301
|
+
mk(1),
|
|
302
|
+
mk(2)
|
|
303
|
+
], undefined);
|
|
304
|
+
let collected = {
|
|
305
|
+
contents: 0
|
|
306
|
+
};
|
|
307
|
+
let processed = await PgChangeFeed$ReventlessPostgres.drain(pool, "feed-log", "sub-1", undefined, async evs => {
|
|
308
|
+
collected.contents = collected.contents + evs.length | 0;
|
|
309
|
+
});
|
|
310
|
+
globalThis.expect(processed).toBe(3);
|
|
311
|
+
globalThis.expect(collected.contents).toBe(3);
|
|
312
|
+
let again = await PgChangeFeed$ReventlessPostgres.drain(pool, "feed-log", "sub-1", undefined, async param => {});
|
|
313
|
+
globalThis.expect(again).toBe(0);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
} else {
|
|
317
|
+
globalThis.test("Postgres integration (skipped — set PG_URL to run)", () => {
|
|
318
|
+
globalThis.expect(true).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export {
|
|
323
|
+
opts,
|
|
324
|
+
jsonObj,
|
|
325
|
+
stored,
|
|
326
|
+
evJson,
|
|
327
|
+
}
|
|
328
|
+
/* url Not a pure module */
|