@reventlessdev/reventless-postgres 3.0.0-alpha.5 → 3.0.0-alpha.50

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 (158) hide show
  1. package/CHANGELOG.md +361 -0
  2. package/README.md +83 -0
  3. package/lib/bs/.compiler.log +2 -2
  4. package/lib/bs/ReventlessPostgres.cmi +0 -0
  5. package/lib/bs/ReventlessPostgres.cmt +0 -0
  6. package/lib/bs/ReventlessPostgres.mlmap +3 -2
  7. package/lib/bs/compiler-info.json +2 -2
  8. package/lib/bs/src/DcbEventLogStorage_Postgres-ReventlessPostgres.cmi +0 -0
  9. package/lib/bs/src/DcbEventLogStorage_Postgres-ReventlessPostgres.cmj +0 -0
  10. package/lib/bs/src/DcbEventLogStorage_Postgres-ReventlessPostgres.cmt +0 -0
  11. package/lib/bs/src/DcbEventLogStorage_Postgres.ast +0 -0
  12. package/lib/bs/src/DcbEventLogStorage_Postgres.res +25 -340
  13. package/lib/bs/src/DcbEventLogStorage_Postgres.res.mjs +32 -348
  14. package/lib/bs/src/DcbEventLogStorage_Postgres_Ops-ReventlessPostgres.cmi +0 -0
  15. package/lib/bs/src/DcbEventLogStorage_Postgres_Ops-ReventlessPostgres.cmj +0 -0
  16. package/lib/bs/src/DcbEventLogStorage_Postgres_Ops-ReventlessPostgres.cmt +0 -0
  17. package/lib/bs/src/DcbEventLogStorage_Postgres_Ops.ast +0 -0
  18. package/lib/bs/src/DcbEventLogStorage_Postgres_Ops.res +350 -0
  19. package/lib/bs/src/DcbEventLogStorage_Postgres_Ops.res.mjs +380 -0
  20. package/lib/bs/src/EventLogChangeFeed-ReventlessPostgres.cmi +0 -0
  21. package/lib/bs/src/EventLogChangeFeed-ReventlessPostgres.cmt +0 -0
  22. package/lib/bs/src/EventLogChangeFeed.ast +0 -0
  23. package/lib/bs/src/EventLogChangeFeed.res +5 -2
  24. package/lib/bs/src/EventLogChangeFeed.res.mjs +3 -3
  25. package/lib/bs/src/EventLogStorage_Postgres-ReventlessPostgres.cmi +0 -0
  26. package/lib/bs/src/EventLogStorage_Postgres-ReventlessPostgres.cmj +0 -0
  27. package/lib/bs/src/EventLogStorage_Postgres-ReventlessPostgres.cmt +0 -0
  28. package/lib/bs/src/EventLogStorage_Postgres.ast +0 -0
  29. package/lib/bs/src/EventLogStorage_Postgres.res +12 -170
  30. package/lib/bs/src/EventLogStorage_Postgres.res.mjs +20 -186
  31. package/lib/bs/src/EventLogStorage_Postgres_Ops-ReventlessPostgres.cmi +0 -0
  32. package/lib/bs/src/EventLogStorage_Postgres_Ops-ReventlessPostgres.cmj +0 -0
  33. package/lib/bs/src/EventLogStorage_Postgres_Ops-ReventlessPostgres.cmt +0 -0
  34. package/lib/bs/src/EventLogStorage_Postgres_Ops.ast +0 -0
  35. package/lib/bs/src/EventLogStorage_Postgres_Ops.res +180 -0
  36. package/lib/bs/src/EventLogStorage_Postgres_Ops.res.mjs +202 -0
  37. package/lib/bs/src/PgChangeFeed-ReventlessPostgres.cmi +0 -0
  38. package/lib/bs/src/PgChangeFeed-ReventlessPostgres.cmt +0 -0
  39. package/lib/bs/src/PgChangeFeed.ast +0 -0
  40. package/lib/bs/src/PgChangeFeed.res +6 -1
  41. package/lib/bs/src/PgChangeFeed.res.mjs +6 -6
  42. package/lib/bs/src/PgDriver-ReventlessPostgres.cmi +0 -0
  43. package/lib/bs/src/PgDriver-ReventlessPostgres.cmt +0 -0
  44. package/lib/bs/src/PgDriver-ReventlessPostgres.cmti +0 -0
  45. package/lib/bs/src/PgDriver.ast +0 -0
  46. package/lib/bs/src/PgDriver.iast +0 -0
  47. package/lib/bs/src/PgSchema-ReventlessPostgres.cmi +0 -0
  48. package/lib/bs/src/PgSchema-ReventlessPostgres.cmt +0 -0
  49. package/lib/bs/src/PgSchema.ast +0 -0
  50. package/lib/bs/src/QueryDbStorage_Postgres-ReventlessPostgres.cmi +0 -0
  51. package/lib/bs/src/QueryDbStorage_Postgres-ReventlessPostgres.cmt +0 -0
  52. package/lib/bs/src/QueryDbStorage_Postgres.ast +0 -0
  53. package/lib/bs/src/QueryDbStorage_Postgres.res +10 -233
  54. package/lib/bs/src/QueryDbStorage_Postgres.res.mjs +25 -204
  55. package/lib/bs/src/QueryDbStorage_Postgres_Ops-ReventlessPostgres.cmi +0 -0
  56. package/lib/bs/src/QueryDbStorage_Postgres_Ops-ReventlessPostgres.cmj +0 -0
  57. package/lib/bs/src/QueryDbStorage_Postgres_Ops-ReventlessPostgres.cmt +0 -0
  58. package/lib/bs/src/QueryDbStorage_Postgres_Ops.ast +0 -0
  59. package/lib/bs/src/QueryDbStorage_Postgres_Ops.res +229 -0
  60. package/lib/bs/src/QueryDbStorage_Postgres_Ops.res.mjs +222 -0
  61. package/lib/bs/src/QueryEnginePostgres-ReventlessPostgres.cmi +0 -0
  62. package/lib/bs/src/QueryEnginePostgres-ReventlessPostgres.cmt +0 -0
  63. package/lib/bs/src/QueryEnginePostgres.ast +0 -0
  64. package/lib/bs/src/QueryEnginePostgres.res +91 -10
  65. package/lib/bs/src/QueryEnginePostgres.res.mjs +89 -21
  66. package/lib/bs/tests/PostgresIntegrationTest-ReventlessPostgres.cmi +0 -0
  67. package/lib/bs/tests/PostgresIntegrationTest-ReventlessPostgres.cmt +0 -0
  68. package/lib/bs/tests/PostgresIntegrationTest.ast +0 -0
  69. package/lib/bs/tests/QueryEnginePostgresListPageTest-ReventlessPostgres.cmi +0 -0
  70. package/lib/bs/tests/QueryEnginePostgresListPageTest-ReventlessPostgres.cmt +0 -0
  71. package/lib/bs/tests/QueryEnginePostgresListPageTest.ast +0 -0
  72. package/lib/bs/tests/QueryEnginePostgresListPageTest.res +76 -0
  73. package/lib/bs/tests/QueryEnginePostgresListPageTest.res.mjs +136 -0
  74. package/lib/ocaml/.compiler.log +2 -2
  75. package/lib/ocaml/DcbEventLogStorage_Postgres-ReventlessPostgres.cmi +0 -0
  76. package/lib/ocaml/DcbEventLogStorage_Postgres-ReventlessPostgres.cmj +0 -0
  77. package/lib/ocaml/DcbEventLogStorage_Postgres-ReventlessPostgres.cmt +0 -0
  78. package/lib/ocaml/DcbEventLogStorage_Postgres.ast +0 -0
  79. package/lib/ocaml/DcbEventLogStorage_Postgres.res +25 -340
  80. package/lib/ocaml/DcbEventLogStorage_Postgres_Ops-ReventlessPostgres.cmi +0 -0
  81. package/lib/ocaml/DcbEventLogStorage_Postgres_Ops-ReventlessPostgres.cmj +0 -0
  82. package/lib/ocaml/DcbEventLogStorage_Postgres_Ops-ReventlessPostgres.cmt +0 -0
  83. package/lib/ocaml/DcbEventLogStorage_Postgres_Ops.ast +0 -0
  84. package/lib/ocaml/DcbEventLogStorage_Postgres_Ops.res +350 -0
  85. package/lib/ocaml/EventLogChangeFeed-ReventlessPostgres.cmi +0 -0
  86. package/lib/ocaml/EventLogChangeFeed-ReventlessPostgres.cmt +0 -0
  87. package/lib/ocaml/EventLogChangeFeed.ast +0 -0
  88. package/lib/ocaml/EventLogChangeFeed.res +5 -2
  89. package/lib/ocaml/EventLogStorage_Postgres-ReventlessPostgres.cmi +0 -0
  90. package/lib/ocaml/EventLogStorage_Postgres-ReventlessPostgres.cmj +0 -0
  91. package/lib/ocaml/EventLogStorage_Postgres-ReventlessPostgres.cmt +0 -0
  92. package/lib/ocaml/EventLogStorage_Postgres.ast +0 -0
  93. package/lib/ocaml/EventLogStorage_Postgres.res +12 -170
  94. package/lib/ocaml/EventLogStorage_Postgres_Ops-ReventlessPostgres.cmi +0 -0
  95. package/lib/ocaml/EventLogStorage_Postgres_Ops-ReventlessPostgres.cmj +0 -0
  96. package/lib/ocaml/EventLogStorage_Postgres_Ops-ReventlessPostgres.cmt +0 -0
  97. package/lib/ocaml/EventLogStorage_Postgres_Ops.ast +0 -0
  98. package/lib/ocaml/EventLogStorage_Postgres_Ops.res +180 -0
  99. package/lib/ocaml/PgChangeFeed-ReventlessPostgres.cmi +0 -0
  100. package/lib/ocaml/PgChangeFeed-ReventlessPostgres.cmt +0 -0
  101. package/lib/ocaml/PgChangeFeed.ast +0 -0
  102. package/lib/ocaml/PgChangeFeed.res +6 -1
  103. package/lib/ocaml/PgDriver-ReventlessPostgres.cmi +0 -0
  104. package/lib/ocaml/PgDriver-ReventlessPostgres.cmt +0 -0
  105. package/lib/ocaml/PgDriver-ReventlessPostgres.cmti +0 -0
  106. package/lib/ocaml/PgDriver.ast +0 -0
  107. package/lib/ocaml/PgDriver.iast +0 -0
  108. package/lib/ocaml/PgSchema-ReventlessPostgres.cmi +0 -0
  109. package/lib/ocaml/PgSchema-ReventlessPostgres.cmt +0 -0
  110. package/lib/ocaml/PgSchema.ast +0 -0
  111. package/lib/ocaml/QueryDbStorage_Postgres-ReventlessPostgres.cmi +0 -0
  112. package/lib/ocaml/QueryDbStorage_Postgres-ReventlessPostgres.cmt +0 -0
  113. package/lib/ocaml/QueryDbStorage_Postgres.ast +0 -0
  114. package/lib/ocaml/QueryDbStorage_Postgres.res +10 -233
  115. package/lib/ocaml/QueryDbStorage_Postgres_Ops-ReventlessPostgres.cmi +0 -0
  116. package/lib/ocaml/QueryDbStorage_Postgres_Ops-ReventlessPostgres.cmj +0 -0
  117. package/lib/ocaml/QueryDbStorage_Postgres_Ops-ReventlessPostgres.cmt +0 -0
  118. package/lib/ocaml/QueryDbStorage_Postgres_Ops.ast +0 -0
  119. package/lib/ocaml/QueryDbStorage_Postgres_Ops.res +229 -0
  120. package/lib/ocaml/QueryEnginePostgres-ReventlessPostgres.cmi +0 -0
  121. package/lib/ocaml/QueryEnginePostgres-ReventlessPostgres.cmt +0 -0
  122. package/lib/ocaml/QueryEnginePostgres.ast +0 -0
  123. package/lib/ocaml/QueryEnginePostgres.res +91 -10
  124. package/lib/ocaml/QueryEnginePostgresListPageTest.res +76 -0
  125. package/lib/ocaml/ReventlessPostgres.cmi +0 -0
  126. package/lib/ocaml/ReventlessPostgres.cmt +0 -0
  127. package/lib/ocaml/ReventlessPostgres.mlmap +3 -0
  128. package/package.json +11 -11
  129. package/rescript.json +2 -1
  130. package/src/DcbEventLogStorage_Postgres.res +25 -340
  131. package/src/DcbEventLogStorage_Postgres.res.mjs +32 -348
  132. package/src/DcbEventLogStorage_Postgres_Ops.res +350 -0
  133. package/src/DcbEventLogStorage_Postgres_Ops.res.mjs +380 -0
  134. package/src/EventLogChangeFeed.res +5 -2
  135. package/src/EventLogChangeFeed.res.mjs +3 -3
  136. package/src/EventLogStorage_Postgres.res +12 -170
  137. package/src/EventLogStorage_Postgres.res.mjs +20 -186
  138. package/src/EventLogStorage_Postgres_Ops.res +180 -0
  139. package/src/EventLogStorage_Postgres_Ops.res.mjs +202 -0
  140. package/src/PgChangeFeed.res +6 -1
  141. package/src/PgChangeFeed.res.mjs +6 -6
  142. package/src/QueryDbStorage_Postgres.res +10 -233
  143. package/src/QueryDbStorage_Postgres.res.mjs +25 -204
  144. package/src/QueryDbStorage_Postgres_Ops.res +229 -0
  145. package/src/QueryDbStorage_Postgres_Ops.res.mjs +222 -0
  146. package/src/QueryEnginePostgres.res +91 -10
  147. package/src/QueryEnginePostgres.res.mjs +89 -21
  148. package/tests/QueryEnginePostgresListPageTest.res +76 -0
  149. package/lib/ocaml/PostgresIntegrationTest-ReventlessPostgres.cmi +0 -0
  150. package/lib/ocaml/PostgresIntegrationTest-ReventlessPostgres.cmj +0 -0
  151. package/lib/ocaml/PostgresIntegrationTest-ReventlessPostgres.cmt +0 -0
  152. package/lib/ocaml/PostgresIntegrationTest.ast +0 -0
  153. package/lib/ocaml/QueryEnginePostgresListPageTest-ReventlessPostgres.cmi +0 -0
  154. package/lib/ocaml/QueryEnginePostgresListPageTest-ReventlessPostgres.cmj +0 -0
  155. package/lib/ocaml/QueryEnginePostgresListPageTest-ReventlessPostgres.cmt +0 -0
  156. package/lib/ocaml/QueryEnginePostgresListPageTest.ast +0 -0
  157. package/tests/PostgresIntegrationTest.res.mjs +0 -377
  158. package/tests/QueryEnginePostgresListPageTest.res.mjs +0 -325
@@ -0,0 +1,229 @@
1
+ // Runtime-pure QueryDb operations for the Postgres backend — JSONB document
2
+ // tables, one per read model.
3
+ //
4
+ // Holds the operation set + all SQL helpers and imports NO `@pulumi/pulumi`
5
+ // (deploy-time only), so the deployed-Lambda runtime can bind ops without dragging
6
+ // a deploy-time dep into its ESM import graph (which fails resolution on real
7
+ // Lambda). The thin `QueryDbStorage_Postgres` wrapper `include`s this and adds the
8
+ // deploy-time `Pulumi.Output`-shaped `storage` record + the `Make` functor.
9
+ // See docs/plans/deployed-lambda-esm-self-containment.md (Rung-3 finding).
10
+
11
+ open ReventlessCore
12
+ open Reventless.ReadModel
13
+
14
+ let tableName = (name: string): string => "qdb_" ++ name->String.replaceAll("-", "_")
15
+
16
+ // Collapse anything outside [A-Za-z0-9_] to _ — predictable, injection-safe idents.
17
+ let sanitizeIdent = (s: string): string =>
18
+ s->String.replaceRegExp(%re("/[^A-Za-z0-9_]/g"), "_")
19
+
20
+ let notExpiredClause = "(expires_at IS NULL OR expires_at > extract(epoch from now()))"
21
+
22
+ // JSONB text extraction for a single top-level field.
23
+ let jsonField = (field: string): string =>
24
+ `(item->>'${field->String.replaceAll("'", "''")}')`
25
+
26
+ let compositeExpr = (fields: array<string>, sep: string): string => {
27
+ let escapedSep = sep->String.replaceAll("'", "''")
28
+ fields->Array.map(jsonField)->Array.join(` || '${escapedSep}' || `)
29
+ }
30
+
31
+ let indexExpressions = (idx: indexConfig): (string, option<string>) => {
32
+ let pkExpr = switch idx.pkFields {
33
+ | Some(fields) if fields->Array.length > 0 => compositeExpr(fields, idx.pkSep->Option.getOr("/"))
34
+ | _ =>
35
+ switch idx.idField {
36
+ | Some(field) => jsonField(field)
37
+ | None => jsonField(idx.index)
38
+ }
39
+ }
40
+ let skExpr = switch idx.skFields {
41
+ | Some(fields) if fields->Array.length > 0 =>
42
+ Some(compositeExpr(fields, idx.skSep->Option.getOr("/")))
43
+ | _ =>
44
+ switch idx.subIdField {
45
+ | Some(field) => Some(jsonField(field))
46
+ | None => None
47
+ }
48
+ }
49
+ (pkExpr, skExpr)
50
+ }
51
+
52
+ let ensureTable = async (~pool, ~table) => {
53
+ await pool->PgDriver.exec(
54
+ `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))`,
55
+ )
56
+ }
57
+
58
+ let ensureIndexes = async (~pool, ~table, ~indexes: array<indexConfig>) => {
59
+ for i in 0 to indexes->Array.length - 1 {
60
+ let idx = indexes->Array.getUnsafe(i)
61
+ let (pkExpr, skExprOpt) = indexExpressions(idx)
62
+ let indexName = `idx_${table}_${sanitizeIdent(idx.index)}`
63
+ let cols = switch skExprOpt {
64
+ | Some(skExpr) => `${pkExpr}, ${skExpr}`
65
+ | None => pkExpr
66
+ }
67
+ await pool->PgDriver.exec(`CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} (${cols})`)
68
+ }
69
+ }
70
+
71
+ let decodeItem = (row: dict<JSON.t>): JSON.t =>
72
+ row->Dict.get("item")->Option.getOr(JSON.Encode.null)
73
+
74
+ let computeSubKey = (item: JSON.t, subIdField: option<string>): string =>
75
+ switch subIdField {
76
+ | None => ""
77
+ | Some(field) =>
78
+ switch item->JSON.Decode.object->Option.flatMap(o => o->Dict.get(field)) {
79
+ | Some(JSON.String(s)) => s
80
+ | _ => ""
81
+ }
82
+ }
83
+
84
+ let withId = (item: JSON.t, partitionKey: string): JSON.t =>
85
+ switch item->JSON.Decode.object {
86
+ | Some(obj) if obj->Dict.get("id")->Option.isNone =>
87
+ let copy = Dict.copy(obj)
88
+ copy->Dict.set("id", JSON.Encode.string(partitionKey))
89
+ JSON.Encode.object(copy)
90
+ | _ => item
91
+ }
92
+
93
+ // Plain operation set for a read model's qdb table. Exposed separately from
94
+ // `makeStorage` so the deployed-Lambda runtime (reventless-aws) can bind ops
95
+ // without touching the Pulumi-shaped `storage` record.
96
+ let makeOperations = (
97
+ ~pool: PgDriver.pool,
98
+ ~name: string,
99
+ ~indexes: array<indexConfig>,
100
+ ~subIdField: option<string>,
101
+ ): QueryDb_Adapter.operations => {
102
+ let table = tableName(name)
103
+ // Schema setup is async and LAZY: kicked off by the first operation, not at
104
+ // construction. A deploy-time caller may build the ops on a machine that
105
+ // cannot reach the database (VPC-private RDS) — only the runtime, which
106
+ // actually calls an operation, may open a connection. Subsequent operations
107
+ // await the same promise (table + index creation runs exactly once).
108
+ let readyRef: ref<option<promise<unit>>> = ref(None)
109
+ let ready = () =>
110
+ switch readyRef.contents {
111
+ | Some(p) => p
112
+ | None =>
113
+ let p = {
114
+ open Promise
115
+ ensureTable(~pool, ~table)->then(() => ensureIndexes(~pool, ~table, ~indexes))
116
+ }
117
+ readyRef := Some(p)
118
+ p
119
+ }
120
+
121
+ let rowsFor = async (id: string): array<JSON.t> => {
122
+ await ready()
123
+ (await pool->PgDriver.query(
124
+ `SELECT item FROM ${table} WHERE partition_key = $1 AND ${notExpiredClause} ORDER BY sub_key ASC`,
125
+ [JSON.Encode.string(id)],
126
+ ))->Array.map(decodeItem)
127
+ }
128
+
129
+ let saveOne = async (id: string, state: JSON.t, ttl: option<int>) => {
130
+ await ready()
131
+ let subKey = computeSubKey(state, subIdField)
132
+ let expiresAt = switch ttl {
133
+ | Some(t) => JSON.Encode.int(t)
134
+ | None => JSON.Encode.null
135
+ }
136
+ let _ = await pool->PgDriver.query(
137
+ `INSERT INTO ${table}(partition_key, sub_key, item, expires_at)
138
+ VALUES ($1, $2, $3::jsonb, $4)
139
+ ON CONFLICT (partition_key, sub_key)
140
+ DO UPDATE SET item = EXCLUDED.item, expires_at = EXCLUDED.expires_at`,
141
+ [
142
+ JSON.Encode.string(id),
143
+ JSON.Encode.string(subKey),
144
+ JSON.Encode.string(JSON.stringify(state)),
145
+ expiresAt,
146
+ ],
147
+ )
148
+ }
149
+
150
+ let load: QueryDb.load<string, JSON.t> = async id => Ok(await rowsFor(id))
151
+
152
+ let loadStream: QueryDb.loadStream<string, JSON.t> =
153
+ id =>
154
+ Effect.promise(() => rowsFor(id))
155
+ ->Stream.fromEffect
156
+ ->Stream.flatMap(arr => Stream.fromIterable(arr))
157
+
158
+ let save: QueryDb.save<string, JSON.t> = async (id, state, _saveMode, ttl) => {
159
+ await saveOne(id, state, ttl)
160
+ Ok()
161
+ }
162
+
163
+ let saveBatch: QueryDb.saveBatch<string, JSON.t> = async batch => {
164
+ // pg has no synchronous multi-statement transaction helper here; the upserts
165
+ // are independent (distinct keys) so a sequential apply is equivalent.
166
+ for i in 0 to batch->Array.length - 1 {
167
+ let (id, state, ttl) = batch->Array.getUnsafe(i)
168
+ await saveOne(id, state, ttl)
169
+ }
170
+ Ok()
171
+ }
172
+
173
+ // Mirror DynamoDB's `ADD #field :inc`: read the counter on the partition item
174
+ // (counters are single-state, sub_key=""), add inc, upsert, return NEW total.
175
+ let count: QueryDb.count<string> = async (id, fieldName, inc) => {
176
+ await ready()
177
+ let existing = (await rowsFor(id))->Array.get(0)->Option.flatMap(JSON.Decode.object)
178
+ let current =
179
+ existing
180
+ ->Option.flatMap(o => o->Dict.get(fieldName))
181
+ ->Option.flatMap(JSON.Decode.float)
182
+ ->Option.mapOr(0, Float.toInt)
183
+ let next = current + inc
184
+ let obj = switch existing {
185
+ | Some(o) => Dict.copy(o)
186
+ | None => Dict.make()
187
+ }
188
+ obj->Dict.set("id", JSON.Encode.string(id))
189
+ obj->Dict.set(fieldName, JSON.Encode.int(next))
190
+ await saveOne(id, JSON.Encode.object(obj), None)
191
+ Ok(next)
192
+ }
193
+
194
+ let delete: QueryDb.delete<string> = async (id, subIdOpt) => {
195
+ await ready()
196
+ let _ = switch subIdOpt {
197
+ | None =>
198
+ await pool->PgDriver.query(
199
+ `DELETE FROM ${table} WHERE partition_key = $1`,
200
+ [JSON.Encode.string(id)],
201
+ )
202
+ | Some((_, subValue)) =>
203
+ await pool->PgDriver.query(
204
+ `DELETE FROM ${table} WHERE partition_key = $1 AND sub_key = $2`,
205
+ [JSON.Encode.string(id), JSON.Encode.string(subValue)],
206
+ )
207
+ }
208
+ Ok()
209
+ }
210
+
211
+ let deleteBatch: QueryDb.deleteBatch<string> = async ids => {
212
+ for i in 0 to ids->Array.length - 1 {
213
+ let (id, subIdOpt) = ids->Array.getUnsafe(i)
214
+ let _ = await delete(id, subIdOpt)
215
+ }
216
+ Ok()
217
+ }
218
+
219
+ let ops: QueryDb_Adapter.operations = {
220
+ load,
221
+ loadStream,
222
+ save,
223
+ saveBatch,
224
+ count,
225
+ delete,
226
+ deleteBatch,
227
+ }
228
+ ops
229
+ }
Binary file
@@ -63,18 +63,18 @@ let subIdSql = (b: paramBuilder, field: string, cmp: QueryEngine.SubId.comparato
63
63
  }
64
64
  }
65
65
 
66
- let notExpired = QueryDbStorage_Postgres.notExpiredClause
66
+ let notExpired = QueryDbStorage_Postgres_Ops.notExpiredClause
67
67
 
68
68
  // Add `id` (from partition_key) when absent — matches allRows/scan in the SQLite
69
69
  // engine; partition-key queries return items as stored.
70
70
  let decodeRow = (~withId, row: dict<JSON.t>): JSON.t => {
71
- let item = QueryDbStorage_Postgres.decodeItem(row)
71
+ let item = QueryDbStorage_Postgres_Ops.decodeItem(row)
72
72
  if withId {
73
73
  let pk = switch row->Dict.get("partition_key") {
74
74
  | Some(JSON.String(s)) => s
75
75
  | _ => ""
76
76
  }
77
- QueryDbStorage_Postgres.withId(item, pk)
77
+ QueryDbStorage_Postgres_Ops.withId(item, pk)
78
78
  } else {
79
79
  item
80
80
  }
@@ -90,7 +90,7 @@ module Make = (P: {let pool: PgDriver.pool}) => {
90
90
  ~ascending=?,
91
91
  ~limit=?,
92
92
  ): array<JSON.t> => {
93
- let table = QueryDbStorage_Postgres.tableName(readModelName)
93
+ let table = QueryDbStorage_Postgres_Ops.tableName(readModelName)
94
94
  let keyStr = switch key {
95
95
  | Some(k) => k
96
96
  | None => valueToText(id)
@@ -114,7 +114,7 @@ module Make = (P: {let pool: PgDriver.pool}) => {
114
114
  }
115
115
 
116
116
  let scan = async (~readModelName, ~filterConfigs, ~limit): array<JSON.t> => {
117
- let table = QueryDbStorage_Postgres.tableName(readModelName)
117
+ let table = QueryDbStorage_Postgres_Ops.tableName(readModelName)
118
118
  let b = {params: []}
119
119
  let where = [notExpired]
120
120
  filterConfigs->Array.forEach(((field, cmp, v)) => where->Array.push(filterSql(b, field, cmp, v)))
@@ -138,7 +138,7 @@ module Make = (P: {let pool: PgDriver.pool}) => {
138
138
  // (`ensureIndexes`) instead of scanning. String comparison mirrors the
139
139
  // resolver's JS path (which only matched string-typed fields).
140
140
  let indexLookup = async (~readModelName, field: string, value: string): array<JSON.t> => {
141
- let table = QueryDbStorage_Postgres.tableName(readModelName)
141
+ let table = QueryDbStorage_Postgres_Ops.tableName(readModelName)
142
142
  let b = {params: []}
143
143
  let sql = `SELECT partition_key, item FROM ${table} WHERE ${col(field)} = ${b->param(
144
144
  JSON.Encode.string(value),
@@ -154,7 +154,7 @@ module Make = (P: {let pool: PgDriver.pool}) => {
154
154
  if ids->Array.length == 0 {
155
155
  []
156
156
  } else {
157
- let table = QueryDbStorage_Postgres.tableName(readModelName)
157
+ let table = QueryDbStorage_Postgres_Ops.tableName(readModelName)
158
158
  let b = {params: []}
159
159
  let idsJson = JSON.Encode.array(ids->Array.map(JSON.Encode.string))
160
160
  let sql = `SELECT partition_key, item FROM ${table} WHERE partition_key = ANY(${b->param(
@@ -184,7 +184,7 @@ module Make = (P: {let pool: PgDriver.pool}) => {
184
184
  ~capability: GraphQL_FragmentGenerator.serverCapability,
185
185
  ~labelField as _,
186
186
  ): option<JSON.t> => {
187
- let table = QueryDbStorage_Postgres.tableName(readModelName)
187
+ let table = QueryDbStorage_Postgres_Ops.tableName(readModelName)
188
188
  let filterDict =
189
189
  argsDict->Dict.get("filter")->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
190
190
  let strNonEmpty = k =>
@@ -297,6 +297,87 @@ module Make = (P: {let pool: PgDriver.pool}) => {
297
297
  }
298
298
  }
299
299
 
300
- let make: QueryDb_Adapter.queryEngineMaker = _allQueryDbs =>
301
- Pulumi.Output.make({QueryEngine.scan, query})
300
+ // Sub-id connection push-down: the `{single}Items(id, filter, first/after/
301
+ // last/before)` query. Keyset over `sub_key` WITHIN one partition — a page
302
+ // reads only what it returns. Reproduces QueryDbResolvers_GraphQL's items
303
+ // resolver (SortKey_Filter + forward/backward keyset), entirely in SQL since
304
+ // sub_key IS the sort key. Cursor = base64 of the sub_key value; comparisons
305
+ // use COLLATE "C" (byte order) to match JS string `<`. Unlike the main list
306
+ // there is no declined-shape fallback — every items shape maps to SQL.
307
+ let itemsPage = async (
308
+ ~readModelName,
309
+ ~subIdField as _: string,
310
+ ~id: string,
311
+ ~argsDict: dict<JSON.t>,
312
+ ): JSON.t => {
313
+ let table = QueryDbStorage_Postgres_Ops.tableName(readModelName)
314
+ let filterDict =
315
+ argsDict->Dict.get("filter")->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
316
+ let fstr = k => filterDict->Dict.get(k)->Option.flatMap(JSON.Decode.string)
317
+ let orderDesc = fstr("order")->Option.mapOr(false, o => o == "DESC")
318
+ let last = argsDict->Dict.get("last")->Option.flatMap(JSON.Decode.float)->Option.map(Float.toInt)
319
+ let first = argsDict->Dict.get("first")->Option.flatMap(JSON.Decode.float)->Option.map(Float.toInt)
320
+ let after = argsDict->Dict.get("after")->Option.flatMap(JSON.Decode.string)
321
+ let before = argsDict->Dict.get("before")->Option.flatMap(JSON.Decode.string)
322
+ let isBackward = last->Option.isSome
323
+
324
+ let sk = `sub_key COLLATE "C"`
325
+ let b = {params: []}
326
+ let where = [`partition_key = ${b->param(JSON.Encode.string(id))}`, notExpired]
327
+ fstr("prefix")->Option.forEach(p =>
328
+ where->Array.push(`starts_with(sub_key, ${b->param(JSON.Encode.string(p))})`)
329
+ )
330
+ fstr("from")->Option.forEach(f => where->Array.push(`${sk} >= ${b->param(JSON.Encode.string(f))}`))
331
+ fstr("to")->Option.forEach(t => where->Array.push(`${sk} <= ${b->param(JSON.Encode.string(t))}`))
332
+ fstr("eq")->Option.forEach(e => where->Array.push(`${sk} = ${b->param(JSON.Encode.string(e))}`))
333
+ // Keyset: forward excludes ≤ after; backward excludes ≥ before.
334
+ switch (isBackward, after, before) {
335
+ | (false, Some(c), _) =>
336
+ where->Array.push(`${sk} > ${b->param(JSON.Encode.string(ReventlessCore.QueryDbListQuery.decodeCursor(c)))}`)
337
+ | (true, _, Some(c)) =>
338
+ where->Array.push(`${sk} < ${b->param(JSON.Encode.string(ReventlessCore.QueryDbListQuery.decodeCursor(c)))}`)
339
+ | _ => ()
340
+ }
341
+ // Page-fetch direction is the LOGICAL order forward, and its OPPOSITE
342
+ // backward (take the N nearest `before`, then reverse to logical) — mirrors
343
+ // the resolver's `reverse = isBackward ? !orderDesc : orderDesc`.
344
+ let fetchDesc = isBackward ? !orderDesc : orderDesc
345
+ let pageSize = (isBackward ? last : first)->Option.getOr(ReventlessCore.QueryDbListQuery.defaultListPageSize)
346
+ let sql = `SELECT partition_key, sub_key, item FROM ${table} WHERE ${where->Array.join(
347
+ " AND ",
348
+ )} ORDER BY ${sk} ${fetchDesc ? "DESC" : "ASC"} LIMIT ${b->param(JSON.Encode.int(pageSize + 1))}`
349
+ let rows = await P.pool->PgDriver.query(sql, b.params)
350
+ let hasMore = rows->Array.length > pageSize
351
+ let taken = rows->Array.slice(~start=0, ~end=pageSize)
352
+ // Backward fetched in reverse-logical order → flip back to logical.
353
+ let logical = isBackward ? taken->Array.toReversed : taken
354
+ let pageItems = logical->Array.map(decodeRow(~withId=false, ...))
355
+ // Cursor = the row's sub_key (== the item's subIdField value).
356
+ let subKeyOf = (row: dict<JSON.t>) =>
357
+ switch row->Dict.get("sub_key") {
358
+ | Some(JSON.String(s)) => s
359
+ | _ => ""
360
+ }
361
+ let cursors = logical->Array.map(subKeyOf)
362
+ let edges =
363
+ pageItems->Array.mapWithIndex((item, i) =>
364
+ Obj.magic({
365
+ "node": item,
366
+ "cursor": ReventlessCore.QueryDbListQuery.encodeCursor(cursors->Array.getUnsafe(i)),
367
+ })
368
+ )
369
+ let startCursor =
370
+ cursors->Array.get(0)->Option.map(ReventlessCore.QueryDbListQuery.encodeCursor)
371
+ let endCursor =
372
+ cursors->Array.get(cursors->Array.length - 1)->Option.map(ReventlessCore.QueryDbListQuery.encodeCursor)
373
+ Obj.magic({
374
+ "edges": edges,
375
+ "pageInfo": {
376
+ "hasNextPage": !isBackward && hasMore,
377
+ "hasPreviousPage": isBackward && hasMore,
378
+ "startCursor": startCursor->Nullable.fromOption,
379
+ "endCursor": endCursor->Nullable.fromOption,
380
+ },
381
+ })
382
+ }
302
383
  }
@@ -233,4 +233,80 @@ switch processEnv->Dict.get("PG_URL") {
233
233
  expect(items->Array.length)->toBe(0)
234
234
  })
235
235
  })
236
+
237
+ // ── itemsPage (sub-id connection) ─────────────────────────────────────────
238
+ // A sub-id read model: partition "order-1" holds 5 lines keyed by `seq`.
239
+ let itemsName = "itemsparity"
240
+ let mkLine = seq => {
241
+ let o = Dict.make()
242
+ o->Dict.set("id", JSON.Encode.string("order-1"))
243
+ o->Dict.set("seq", JSON.Encode.string(seq))
244
+ JSON.Encode.object(o)
245
+ }
246
+ let seqs = ["a", "b", "c", "d", "e"]
247
+ beforeAllAsync(async () => {
248
+ let ops = QueryDbStorage_Postgres.makeOperations(
249
+ ~pool,
250
+ ~name=itemsName,
251
+ ~indexes=[],
252
+ ~subIdField=Some("seq"),
253
+ )
254
+ for i in 0 to seqs->Array.length - 1 {
255
+ let _ = await ops.save("order-1", mkLine(seqs->Array.getUnsafe(i)), QueryDb.Any, None)
256
+ }
257
+ })
258
+
259
+ // Extract node `seq` values in edge order.
260
+ let itemSeqs = (conn: JSON.t) =>
261
+ conn
262
+ ->field("edges")
263
+ ->Option.flatMap(JSON.Decode.array)
264
+ ->Option.getOr([])
265
+ ->Array.filterMap(e => e->field("node")->Option.flatMap(n => n->str("seq")))
266
+
267
+ let pageInfoBool = (conn, key) =>
268
+ conn->field("pageInfo")->Option.getOr(JSON.Encode.null)->field(key)->Option.flatMap(JSON.Decode.bool)->Option.getOr(false)
269
+
270
+ let itemsPage = args =>
271
+ Eng.itemsPage(~readModelName=itemsName, ~subIdField="seq", ~id="order-1", ~argsDict=argsOf(args))
272
+
273
+ describe("QueryEnginePostgres itemsPage (sub-id keyset)", () => {
274
+ testPromise("bare → all lines ASC, no next page", async () => {
275
+ let r = await itemsPage([])
276
+ expect(r->itemSeqs)->toEqual(["a", "b", "c", "d", "e"])
277
+ expect(pageInfoBool(r, "hasNextPage"))->toBe(false)
278
+ })
279
+ testPromise("first:2 → [a,b], hasNextPage", async () => {
280
+ let r = await itemsPage([("first", JSON.Encode.int(2))])
281
+ expect(r->itemSeqs)->toEqual(["a", "b"])
282
+ expect(pageInfoBool(r, "hasNextPage"))->toBe(true)
283
+ })
284
+ testPromise("first:2 after b → [c,d]", async () => {
285
+ let r = await itemsPage([("first", JSON.Encode.int(2)), ("after", JSON.Encode.string(cur("b")))])
286
+ expect(r->itemSeqs)->toEqual(["c", "d"])
287
+ })
288
+ testPromise("order DESC → [e..a]", async () => {
289
+ let r = await itemsPage([("filter", filterOf([("order", JSON.Encode.string("DESC"))]))])
290
+ expect(r->itemSeqs)->toEqual(["e", "d", "c", "b", "a"])
291
+ })
292
+ testPromise("filter eq c → [c]", async () => {
293
+ let r = await itemsPage([("filter", filterOf([("eq", JSON.Encode.string("c"))]))])
294
+ expect(r->itemSeqs)->toEqual(["c"])
295
+ })
296
+ testPromise("filter from b to d → [b,c,d]", async () => {
297
+ let r = await itemsPage([
298
+ ("filter", filterOf([("from", JSON.Encode.string("b")), ("to", JSON.Encode.string("d"))])),
299
+ ])
300
+ expect(r->itemSeqs)->toEqual(["b", "c", "d"])
301
+ })
302
+ testPromise("prefix c → [c]", async () => {
303
+ let r = await itemsPage([("filter", filterOf([("prefix", JSON.Encode.string("c"))]))])
304
+ expect(r->itemSeqs)->toEqual(["c"])
305
+ })
306
+ testPromise("backward last:2 before d → [b,c] (logical ASC)", async () => {
307
+ let r = await itemsPage([("last", JSON.Encode.int(2)), ("before", JSON.Encode.string(cur("d")))])
308
+ expect(r->itemSeqs)->toEqual(["b", "c"])
309
+ expect(pageInfoBool(r, "hasPreviousPage"))->toBe(true)
310
+ })
311
+ })
236
312
  }
Binary file
Binary file
@@ -1,11 +1,14 @@
1
1
  randjbuildsystem
2
2
  DcbEventLogStorage_Postgres
3
+ DcbEventLogStorage_Postgres_Ops
3
4
  EventLogChangeFeed
4
5
  EventLogStorage_Postgres
6
+ EventLogStorage_Postgres_Ops
5
7
  PgChangeFeed
6
8
  PgDriver
7
9
  PgSchema
8
10
  PostgresIntegrationTest
9
11
  QueryDbStorage_Postgres
12
+ QueryDbStorage_Postgres_Ops
10
13
  QueryEnginePostgres
11
14
  QueryEnginePostgresListPageTest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-postgres",
3
- "version": "3.0.0-alpha.5",
3
+ "version": "3.0.0-alpha.50",
4
4
  "description": "Postgres storage backend for Reventless (classic + DCB event logs, query DB, change feed). Connection-string only — RDS/Aurora, any managed provider, or a local container.",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -22,21 +22,21 @@
22
22
  "dependencies": {
23
23
  "pg": "^8.13.1",
24
24
  "sury": "11.0.0-alpha.4",
25
- "@reventlessdev/rescript-jest": "1.0.0-alpha.6",
26
- "@reventlessdev/rescript-effect": "0.1.0-alpha.25",
27
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
28
- "@reventlessdev/reventless-core": "3.0.0-alpha.143",
29
- "@reventlessdev/reventless-infra": "3.0.0-alpha.89",
30
- "@reventlessdev/reventless-spec": "3.0.0-alpha.67"
25
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.31",
26
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
27
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
28
+ "@reventlessdev/reventless-core": "3.0.0-alpha.186",
29
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.106",
30
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.81"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/pg": "^8.11.10",
34
- "rescript": "^12.3.0",
34
+ "rescript": "12.3.0",
35
35
  "sury-ppx": "11.0.0-alpha.2",
36
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.51"
36
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.56"
37
37
  },
38
38
  "peerDependencies": {
39
- "rescript": "^12.3.0"
39
+ "rescript": "12.3.0"
40
40
  },
41
41
  "publishConfig": {
42
42
  "registry": "https://registry.npmjs.org"
@@ -44,7 +44,7 @@
44
44
  "repository": {
45
45
  "type": "git",
46
46
  "url": "git+https://github.com/ReventlessDev/reventless-core.git",
47
- "directory": "reventless/reventless-postgres"
47
+ "directory": "reventless/postgres"
48
48
  },
49
49
  "scripts": {
50
50
  "build": "rescript build",
package/rescript.json CHANGED
@@ -19,7 +19,8 @@
19
19
  },
20
20
  {
21
21
  "dir": "tests",
22
- "subdirs": true
22
+ "subdirs": true,
23
+ "type": "dev"
23
24
  }
24
25
  ],
25
26
  "dependencies": [