@supalive/core 0.1.3 → 0.1.5

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 (212) hide show
  1. package/README.md +308 -1
  2. package/dist/helper-CiacMqje.d.ts +14 -0
  3. package/dist/helper-CiacMqje.d.ts.map +1 -0
  4. package/dist/helper-zdJT5FUc.js +58 -0
  5. package/dist/helper-zdJT5FUc.js.map +1 -0
  6. package/dist/index-CTyXKcq_.d.ts +1736 -0
  7. package/dist/index-CTyXKcq_.d.ts.map +1 -0
  8. package/dist/mysql-C8SN443m.d.ts +104 -0
  9. package/dist/mysql-C8SN443m.d.ts.map +1 -0
  10. package/dist/mysql-DELSRrpY.js +569 -0
  11. package/dist/mysql-DELSRrpY.js.map +1 -0
  12. package/dist/overlap-checker-CVkbbuEQ.js +187 -0
  13. package/dist/overlap-checker-CVkbbuEQ.js.map +1 -0
  14. package/dist/postgres-DE-XyNAQ.js +823 -0
  15. package/dist/postgres-DE-XyNAQ.js.map +1 -0
  16. package/dist/postgres-D_gwCHx-.d.ts +108 -0
  17. package/dist/postgres-D_gwCHx-.d.ts.map +1 -0
  18. package/dist/query-CbQLjeME.js +713 -0
  19. package/dist/query-CbQLjeME.js.map +1 -0
  20. package/dist/realtime_db-D9p2X2PH.js +246 -0
  21. package/dist/realtime_db-D9p2X2PH.js.map +1 -0
  22. package/dist/router-DlTYWpop.js +81 -0
  23. package/dist/router-DlTYWpop.js.map +1 -0
  24. package/dist/schema-SKcIWHfo.js +167 -0
  25. package/dist/schema-SKcIWHfo.js.map +1 -0
  26. package/dist/src/client/index.d.ts +2 -17
  27. package/dist/src/client/index.js +1151 -18
  28. package/dist/src/client/index.js.map +1 -1
  29. package/dist/src/exports/mysql.d.ts +2 -2
  30. package/dist/src/exports/mysql.js +2 -2
  31. package/dist/src/exports/postgres.d.ts +2 -2
  32. package/dist/src/exports/postgres.js +2 -2
  33. package/dist/src/exports/procedure.d.ts +3 -5
  34. package/dist/src/exports/procedure.js +5 -5
  35. package/dist/src/exports/server.d.ts +368 -2
  36. package/dist/src/exports/server.d.ts.map +1 -1
  37. package/dist/src/exports/server.js +1346 -2
  38. package/dist/src/exports/server.js.map +1 -1
  39. package/dist/src/exports/subscription-manager-worker-entry.d.ts +1 -0
  40. package/dist/src/exports/subscription-manager-worker-entry.js +115 -0
  41. package/dist/src/exports/subscription-manager-worker-entry.js.map +1 -0
  42. package/dist/src/exports/types.d.ts +4 -6
  43. package/dist/src/exports/types.js +5 -6
  44. package/dist/subscription-worker-BurdPO8d.js +511 -0
  45. package/dist/subscription-worker-BurdPO8d.js.map +1 -0
  46. package/dist/types_client_rpc-CIsjQQGG.js +105 -0
  47. package/dist/types_client_rpc-CIsjQQGG.js.map +1 -0
  48. package/dist/types_db-Ctf-ksoy.js +168 -0
  49. package/dist/types_db-Ctf-ksoy.js.map +1 -0
  50. package/dist/types_server-Ch56y8L3.d.ts +216 -0
  51. package/dist/types_server-Ch56y8L3.d.ts.map +1 -0
  52. package/dist/types_server-Dk6o_B7N.js +24 -0
  53. package/dist/types_server-Dk6o_B7N.js.map +1 -0
  54. package/package.json +3 -3
  55. package/dist/src/client/caller_client.d.ts +0 -42
  56. package/dist/src/client/caller_client.d.ts.map +0 -1
  57. package/dist/src/client/caller_client.js +0 -77
  58. package/dist/src/client/caller_client.js.map +0 -1
  59. package/dist/src/client/client.d.ts +0 -149
  60. package/dist/src/client/client.d.ts.map +0 -1
  61. package/dist/src/client/client.js +0 -308
  62. package/dist/src/client/client.js.map +0 -1
  63. package/dist/src/client/index.d.ts.map +0 -1
  64. package/dist/src/client/sub_key.d.ts +0 -20
  65. package/dist/src/client/sub_key.d.ts.map +0 -1
  66. package/dist/src/client/sub_key.js +0 -37
  67. package/dist/src/client/sub_key.js.map +0 -1
  68. package/dist/src/client/ws_client_manager.d.ts +0 -268
  69. package/dist/src/client/ws_client_manager.d.ts.map +0 -1
  70. package/dist/src/client/ws_client_manager.js +0 -1013
  71. package/dist/src/client/ws_client_manager.js.map +0 -1
  72. package/dist/src/config.d.ts +0 -117
  73. package/dist/src/config.d.ts.map +0 -1
  74. package/dist/src/config.js +0 -2
  75. package/dist/src/config.js.map +0 -1
  76. package/dist/src/db/bytes.d.ts +0 -4
  77. package/dist/src/db/bytes.d.ts.map +0 -1
  78. package/dist/src/db/bytes.js +0 -24
  79. package/dist/src/db/bytes.js.map +0 -1
  80. package/dist/src/db/cache.d.ts +0 -30
  81. package/dist/src/db/cache.d.ts.map +0 -1
  82. package/dist/src/db/cache.js +0 -153
  83. package/dist/src/db/cache.js.map +0 -1
  84. package/dist/src/db/codec.d.ts +0 -10
  85. package/dist/src/db/codec.d.ts.map +0 -1
  86. package/dist/src/db/codec.js +0 -73
  87. package/dist/src/db/codec.js.map +0 -1
  88. package/dist/src/db/context.d.ts +0 -28
  89. package/dist/src/db/context.d.ts.map +0 -1
  90. package/dist/src/db/context.js +0 -147
  91. package/dist/src/db/context.js.map +0 -1
  92. package/dist/src/db/db.d.ts +0 -154
  93. package/dist/src/db/db.d.ts.map +0 -1
  94. package/dist/src/db/db.js +0 -23
  95. package/dist/src/db/db.js.map +0 -1
  96. package/dist/src/db/mysql.d.ts +0 -120
  97. package/dist/src/db/mysql.d.ts.map +0 -1
  98. package/dist/src/db/mysql.js +0 -708
  99. package/dist/src/db/mysql.js.map +0 -1
  100. package/dist/src/db/postgres.d.ts +0 -116
  101. package/dist/src/db/postgres.d.ts.map +0 -1
  102. package/dist/src/db/postgres.js +0 -998
  103. package/dist/src/db/postgres.js.map +0 -1
  104. package/dist/src/db/query.d.ts +0 -174
  105. package/dist/src/db/query.d.ts.map +0 -1
  106. package/dist/src/db/query.js +0 -790
  107. package/dist/src/db/query.js.map +0 -1
  108. package/dist/src/db/query_definition.d.ts +0 -54
  109. package/dist/src/db/query_definition.d.ts.map +0 -1
  110. package/dist/src/db/query_definition.js +0 -47
  111. package/dist/src/db/query_definition.js.map +0 -1
  112. package/dist/src/db/realtime_db.d.ts +0 -19
  113. package/dist/src/db/realtime_db.d.ts.map +0 -1
  114. package/dist/src/db/realtime_db.js +0 -98
  115. package/dist/src/db/realtime_db.js.map +0 -1
  116. package/dist/src/db/schema.d.ts +0 -110
  117. package/dist/src/db/schema.d.ts.map +0 -1
  118. package/dist/src/db/schema.js +0 -47
  119. package/dist/src/db/schema.js.map +0 -1
  120. package/dist/src/db/types_client_rpc.d.ts +0 -157
  121. package/dist/src/db/types_client_rpc.d.ts.map +0 -1
  122. package/dist/src/db/types_client_rpc.js +0 -106
  123. package/dist/src/db/types_client_rpc.js.map +0 -1
  124. package/dist/src/db/types_db.d.ts +0 -236
  125. package/dist/src/db/types_db.d.ts.map +0 -1
  126. package/dist/src/db/types_db.js +0 -188
  127. package/dist/src/db/types_db.js.map +0 -1
  128. package/dist/src/exports/mysql.d.ts.map +0 -1
  129. package/dist/src/exports/mysql.js.map +0 -1
  130. package/dist/src/exports/postgres.d.ts.map +0 -1
  131. package/dist/src/exports/postgres.js.map +0 -1
  132. package/dist/src/exports/procedure.d.ts.map +0 -1
  133. package/dist/src/exports/procedure.js.map +0 -1
  134. package/dist/src/exports/types.d.ts.map +0 -1
  135. package/dist/src/exports/types.js.map +0 -1
  136. package/dist/src/helper.d.ts +0 -11
  137. package/dist/src/helper.d.ts.map +0 -1
  138. package/dist/src/helper.js +0 -45
  139. package/dist/src/helper.js.map +0 -1
  140. package/dist/src/logger.d.ts +0 -7
  141. package/dist/src/logger.d.ts.map +0 -1
  142. package/dist/src/logger.js +0 -45
  143. package/dist/src/logger.js.map +0 -1
  144. package/dist/src/router/index.d.ts +0 -4
  145. package/dist/src/router/index.d.ts.map +0 -1
  146. package/dist/src/router/index.js +0 -4
  147. package/dist/src/router/index.js.map +0 -1
  148. package/dist/src/router/procedure.d.ts +0 -204
  149. package/dist/src/router/procedure.d.ts.map +0 -1
  150. package/dist/src/router/procedure.js +0 -122
  151. package/dist/src/router/procedure.js.map +0 -1
  152. package/dist/src/router/router.d.ts +0 -88
  153. package/dist/src/router/router.d.ts.map +0 -1
  154. package/dist/src/router/router.js +0 -88
  155. package/dist/src/router/router.js.map +0 -1
  156. package/dist/src/server/hash.d.ts +0 -12
  157. package/dist/src/server/hash.d.ts.map +0 -1
  158. package/dist/src/server/hash.js +0 -20
  159. package/dist/src/server/hash.js.map +0 -1
  160. package/dist/src/server/hash.test.d.ts +0 -2
  161. package/dist/src/server/hash.test.d.ts.map +0 -1
  162. package/dist/src/server/hash.test.js +0 -43
  163. package/dist/src/server/hash.test.js.map +0 -1
  164. package/dist/src/server/index.d.ts +0 -8
  165. package/dist/src/server/index.d.ts.map +0 -1
  166. package/dist/src/server/index.js +0 -7
  167. package/dist/src/server/index.js.map +0 -1
  168. package/dist/src/server/overlap-checker.d.ts +0 -18
  169. package/dist/src/server/overlap-checker.d.ts.map +0 -1
  170. package/dist/src/server/overlap-checker.js +0 -247
  171. package/dist/src/server/overlap-checker.js.map +0 -1
  172. package/dist/src/server/sub_hash.d.ts +0 -23
  173. package/dist/src/server/sub_hash.d.ts.map +0 -1
  174. package/dist/src/server/sub_hash.js +0 -38
  175. package/dist/src/server/sub_hash.js.map +0 -1
  176. package/dist/src/server/subscription-manager-client.d.ts +0 -120
  177. package/dist/src/server/subscription-manager-client.d.ts.map +0 -1
  178. package/dist/src/server/subscription-manager-client.js +0 -207
  179. package/dist/src/server/subscription-manager-client.js.map +0 -1
  180. package/dist/src/server/subscription-manager-types.d.ts +0 -168
  181. package/dist/src/server/subscription-manager-types.d.ts.map +0 -1
  182. package/dist/src/server/subscription-manager-types.js +0 -94
  183. package/dist/src/server/subscription-manager-types.js.map +0 -1
  184. package/dist/src/server/subscription-manager-worker-entry.d.ts +0 -2
  185. package/dist/src/server/subscription-manager-worker-entry.d.ts.map +0 -1
  186. package/dist/src/server/subscription-manager-worker-entry.js +0 -127
  187. package/dist/src/server/subscription-manager-worker-entry.js.map +0 -1
  188. package/dist/src/server/subscription-manager-worker-protocol.d.ts +0 -40
  189. package/dist/src/server/subscription-manager-worker-protocol.d.ts.map +0 -1
  190. package/dist/src/server/subscription-manager-worker-protocol.js +0 -20
  191. package/dist/src/server/subscription-manager-worker-protocol.js.map +0 -1
  192. package/dist/src/server/subscription-manager.d.ts +0 -32
  193. package/dist/src/server/subscription-manager.d.ts.map +0 -1
  194. package/dist/src/server/subscription-manager.js +0 -249
  195. package/dist/src/server/subscription-manager.js.map +0 -1
  196. package/dist/src/server/subscription-manager.test.d.ts +0 -2
  197. package/dist/src/server/subscription-manager.test.d.ts.map +0 -1
  198. package/dist/src/server/subscription-manager.test.js +0 -610
  199. package/dist/src/server/subscription-manager.test.js.map +0 -1
  200. package/dist/src/server/subscription-worker.d.ts +0 -32
  201. package/dist/src/server/subscription-worker.d.ts.map +0 -1
  202. package/dist/src/server/subscription-worker.js +0 -190
  203. package/dist/src/server/subscription-worker.js.map +0 -1
  204. package/dist/src/server/supalive-server.d.ts +0 -158
  205. package/dist/src/server/supalive-server.d.ts.map +0 -1
  206. package/dist/src/server/supalive-server.js +0 -1048
  207. package/dist/src/server/supalive-server.js.map +0 -1
  208. package/dist/src/server/types_server.d.ts +0 -96
  209. package/dist/src/server/types_server.d.ts.map +0 -1
  210. package/dist/src/server/types_server.js +0 -36
  211. package/dist/src/server/types_server.js.map +0 -1
  212. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -1,998 +0,0 @@
1
- import { Pool } from "pg";
2
- import { normalizeIdToBytes, OccConflictError, normalizeToBytes } from "./types_db";
3
- import { utf8Encode } from "./bytes";
4
- // pg's bytea encoder checks `Buffer.isBuffer`; a plain Uint8Array falls through
5
- // to its scalar path and corrupts the bytes. Wrap as a zero-copy Buffer view.
6
- function toBufferView(bytes) {
7
- if (Buffer.isBuffer(bytes))
8
- return bytes;
9
- return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
10
- }
11
- import { checkLogsAffectReadSet, mapReadSetToRaw } from "../server/overlap-checker";
12
- import { supaliveStringify } from "../helper";
13
- export class PgDatabase {
14
- constructor(logger, config, pool = undefined) {
15
- this.logger = logger;
16
- this.config = config;
17
- if (pool) {
18
- this.pool = pool;
19
- }
20
- else {
21
- this.pool = new Pool(config);
22
- }
23
- }
24
- logger;
25
- config;
26
- pool;
27
- sqlBuilder = pgSql;
28
- dbType = "postgres";
29
- // Lazy per-table column-type cache. The apply path consults this for
30
- // unnest carriers/casts in batched writes; we never re-query pg_attribute
31
- // for a table we've already seen unless the cache is explicitly
32
- // invalidated (e.g. after a migration). Shared with tx clients via
33
- // adoptRegistries so a single startup load services all transactions.
34
- columnTypes = new Map();
35
- // Compiled-SQL cache keyed by (op, table, sorted column signature).
36
- // Mirrors columnTypes — invalidated together when types change.
37
- sqlCache = new Map();
38
- /**
39
- * Fetches actual column types from Postgres for `table` and returns a
40
- * map of DB column name → PG type string (as produced by `format_type`,
41
- * e.g. `bigint`, `integer`, `character varying(32)`, `text[]`, `jsonb`).
42
- * No caching here — see {@link getColumnTypes} for the cached path.
43
- */
44
- async loadColumnTypesFromPg(table) {
45
- const result = await this.query(`SELECT a.attname AS column_name, format_type(a.atttypid, a.atttypmod) AS pg_type
46
- FROM pg_attribute a
47
- WHERE a.attrelid = $1::regclass AND a.attnum > 0 AND NOT a.attisdropped
48
- ORDER BY a.attnum`, [table]);
49
- // PG folds unquoted identifiers to lowercase on the server side, so
50
- // pg_attribute.attname comes back lowercase. JS-side column keys
51
- // (`entry.data`) often arrive in camelCase from the schema layer; we
52
- // lookup via lowercase to bridge the two cases without making callers
53
- // worry about it.
54
- const out = new Map();
55
- for (const row of result.rows)
56
- out.set(row.column_name.toLowerCase(), row.pg_type);
57
- return out;
58
- }
59
- /**
60
- * Synchronous column-type lookup. The apply path calls this on every
61
- * batched write, so it can't await — populate the cache via
62
- * {@link bootstrapColumnTypes} at server start. Throws if `table` is
63
- * missing (programmer error / bootstrap skipped).
64
- */
65
- getColumnTypes(table) {
66
- const hit = this.columnTypes.get(table);
67
- if (!hit) {
68
- this.logger.warn({ table }, "PgDatabase: no cached column types fallback to non-batched writes");
69
- return null;
70
- }
71
- return hit;
72
- }
73
- /**
74
- * Eagerly load column types into the in-memory cache. With a Redis
75
- * client supplied, attempts the cache key first and falls through to
76
- * Postgres on miss (writing the result back to Redis). This is the
77
- * "one server queries PG, the rest read from Redis" pattern for cold
78
- * starts in a multi-instance deploy. Without Redis, always queries PG.
79
- *
80
- * Pass `tables` to limit the load to a specific list; otherwise every
81
- * user table in the connected database is loaded.
82
- *
83
- * The Redis cache key should encode whatever invalidates types in your
84
- * environment (typically a schema version / deploy version). The
85
- * subscriber in supalive-server.ts is expected to publish a reload
86
- * signal after migrations and invoke {@link invalidateColumnTypes}.
87
- */
88
- async bootstrapColumnTypes(opts) {
89
- this.invalidateColumnTypes();
90
- const { redis, cacheKey, tables } = opts;
91
- // Redis hit path: deserialize the full table → column → pgType map
92
- // and populate in one shot, skipping any pg_attribute round-trips.
93
- if (redis) {
94
- const cached = await redis.getCachedTableColumnTypes(cacheKey);
95
- if (cached) {
96
- for (const [table, cols] of Object.entries(cached)) {
97
- this.columnTypes.set(table, new Map(Object.entries(cols)));
98
- }
99
- return;
100
- }
101
- }
102
- // Cache miss (or no Redis): query Postgres for the listed tables
103
- // (or enumerate all user tables) and populate.
104
- const names = tables ?? await this.listUserTables();
105
- for (const t of names) {
106
- if (this.columnTypes.has(t))
107
- continue;
108
- const fresh = await this.loadColumnTypesFromPg(t);
109
- this.columnTypes.set(t, fresh);
110
- }
111
- // Write back to Redis so subsequent server instances skip the PG hop.
112
- if (redis) {
113
- const dump = {};
114
- for (const [table, cols] of this.columnTypes) {
115
- dump[table] = Object.fromEntries([...cols.entries()].sort());
116
- }
117
- await redis.setCachedTableColumnsTypes(cacheKey, dump);
118
- }
119
- }
120
- /**
121
- * Invalidate cached types (and dependent SQL) for `table`, or the entire
122
- * cache if `table` is omitted. The Redis-pubsub reload signal in
123
- * supalive-server.ts is expected to call this on schema changes.
124
- */
125
- invalidateColumnTypes(table) {
126
- if (table) {
127
- this.columnTypes.delete(table);
128
- for (const k of [...this.sqlCache.keys()]) {
129
- if (k.includes(`:${table}:`) || k === `d:${table}`)
130
- this.sqlCache.delete(k);
131
- }
132
- }
133
- else {
134
- this.columnTypes.clear();
135
- this.sqlCache.clear();
136
- }
137
- }
138
- /** Enumerate the user-defined tables in the connected database — used
139
- * by bootstrapColumnTypes() when no explicit list is provided. */
140
- async listUserTables() {
141
- const res = await this.query(`SELECT c.relname FROM pg_class c
142
- JOIN pg_namespace n ON n.oid = c.relnamespace
143
- WHERE c.relkind = 'r' AND n.nspname = ANY (current_schemas(false))
144
- AND c.relname NOT IN ('commit_logs','metadata')`);
145
- return res.rows.map((r) => r.relname);
146
- }
147
- migrationSql = [
148
- // Core OCC timestamp sequence
149
- `CREATE SEQUENCE IF NOT EXISTS global_commit_ts START 1 INCREMENT 1 NO CYCLE;`,
150
- `CREATE TABLE IF NOT EXISTS commit_logs (
151
- id BYTEA NOT NULL,
152
- ts BIGINT NOT NULL,
153
- table_id BYTEA NOT NULL,
154
- data BYTEA NOT NULL,
155
- PRIMARY KEY (ts, table_id, id)
156
- )`,
157
- `CREATE INDEX IF NOT EXISTS commit_logs_by_table_and_id ON commit_logs (table_id, id, ts)`,
158
- `CREATE INDEX IF NOT EXISTS commit_logs_by_table_ts_and_id ON commit_logs (table_id, ts, id)`,
159
- // Metadata table
160
- `CREATE TABLE IF NOT EXISTS metadata (
161
- meta_key TEXT PRIMARY KEY,
162
- meta_value BIGINT NOT NULL
163
- );`,
164
- `INSERT INTO metadata (meta_key, meta_value) VALUES ('latest_committed_ts', 0) ON CONFLICT (meta_key) DO NOTHING;`
165
- ];
166
- async query(sql, params = []) {
167
- const client = await this.pool.connect();
168
- try {
169
- const result = await client.query(sql, params);
170
- return {
171
- rows: result.rows,
172
- rowCount: result.rowCount,
173
- };
174
- }
175
- catch (err) {
176
- throw translatePgError(err);
177
- }
178
- finally {
179
- client.release();
180
- }
181
- }
182
- async getClient() {
183
- return await this.pool.connect();
184
- }
185
- async getTransactionClient() {
186
- const client = await this.pool.connect();
187
- const tx = new PgTxDatabase(this.logger, this.config, this.pool, client);
188
- // Tx clients are short-lived. Share the parent's caches by reference so
189
- // (a) types loaded once at startup are visible inside transactions, and
190
- // (b) the SQL cache grows across the process rather than per-tx.
191
- tx.adoptCaches(this.columnTypes, this.sqlCache);
192
- return tx;
193
- }
194
- /** Internal: replace this instance's caches with shared references from
195
- * a parent. Used by PgDatabase.getTransactionClient. */
196
- adoptCaches(columnTypes, sqlCache) {
197
- this.columnTypes = columnTypes;
198
- this.sqlCache = sqlCache;
199
- }
200
- async getLatestSnapshotTimestamp() {
201
- const result = await this.query("SELECT meta_value FROM metadata WHERE meta_key = 'latest_committed_ts'");
202
- return BigInt(result.rows[0]?.meta_value ?? 0) || -1n;
203
- }
204
- async updateLatestSnapshotTimestamp(commitTs) {
205
- await this.query("UPDATE metadata SET meta_value = $1 WHERE meta_key = 'latest_committed_ts' AND meta_value < $1", [commitTs]);
206
- }
207
- async getNextTimestamp() {
208
- const result = await this.query("SELECT nextval('global_commit_ts') as nextval");
209
- return BigInt(result.rows[0]?.nextval ?? "0") || -1n;
210
- }
211
- // Runs on the pool, not the tx client — intentionally. This keeps the
212
- // transaction window short, but it means `prevData` is not snapshot-
213
- // consistent with the eventual commitTs (a peer may have moved the row
214
- // between beginTs and this SELECT). That's safe because:
215
- // 1. The per-row CAS in pgApplyWrites is the real snapshot fence. If a
216
- // peer committed in our staleness window, the CAS sees a moved
217
- // commit_ts and throws OccConflictError → retry.
218
- // 2. The only window where CAS passes with "stale" prevData is when
219
- // the current row at our SELECT moment matches `expectedCommitTs`;
220
- // by construction nobody committed between our captured ts and now,
221
- // so prevData IS consistent with the ts we're CAS-ing against.
222
- // 3. The downstream consumer (overlap-checker.checkRowWriteAffectsRange)
223
- // is conservative on missing/odd prevData — "unknown" verdicts still
224
- // invalidate, so the worst case here is a spurious invalidation,
225
- // never a missed one.
226
- // Locked in by postgres.attach-prev-data.test.ts.
227
- async attachPrevDataToWriteSet(writeSet, readSet) {
228
- // Index prior point reads by (table, id) so we can carry the
229
- // read-time commit_ts onto matching writes. Iterate in reverse so the
230
- // last read wins (handles handlers that read the same row twice).
231
- const readTsByKey = new Map();
232
- for (let i = readSet.length - 1; i >= 0; i--) {
233
- const r = readSet[i];
234
- if (r.kind !== "point" || r.expectedCommitTs === undefined)
235
- continue;
236
- const key = `${r.table} ${r.id}`;
237
- if (!readTsByKey.has(key))
238
- readTsByKey.set(key, r.expectedCommitTs);
239
- }
240
- const byTable = new Map();
241
- for (const entry of writeSet) {
242
- if (entry.op !== "update" && entry.op !== "delete")
243
- continue;
244
- const bucket = byTable.get(entry.table);
245
- if (bucket)
246
- bucket.push(entry);
247
- else
248
- byTable.set(entry.table, [entry]);
249
- }
250
- if (byTable.size === 0)
251
- return;
252
- for (const [table, entries] of byTable) {
253
- const ids = entries.map((e) => e.id);
254
- const placeholders = ids.map((_, i) => `$${i + 1}`).join(", ");
255
- // No FOR UPDATE — the per-row CAS on the subsequent UPDATE/DELETE is
256
- // the fence. If a peer mutated the row since our read, the CAS will
257
- // see a stale expected commit_ts and throw OCC.
258
- const sql = `SELECT * FROM ${table} WHERE id IN (${placeholders})`;
259
- const result = await this.query(sql, ids);
260
- const byId = new Map();
261
- for (const row of result.rows) {
262
- byId.set(String(row.id), row);
263
- }
264
- for (const entry of entries) {
265
- const row = byId.get(entry.id);
266
- const key = `${entry.table} ${entry.id}`;
267
- const priorReadTs = readTsByKey.get(key);
268
- if (row) {
269
- entry.prevData = row;
270
- // Prefer the read-time ts so read-then-update patterns get
271
- // beginTs-level isolation; fall back to current row ts for
272
- // blind writes (no prior read).
273
- entry.expectedCommitTs = priorReadTs !== undefined
274
- ? priorReadTs
275
- : BigInt(row.commit_ts);
276
- }
277
- else if (priorReadTs !== undefined) {
278
- // Row gone now, but we had read it earlier: carry the read ts —
279
- // the CAS will then mismatch (row deleted) and throw OCC.
280
- entry.expectedCommitTs = priorReadTs;
281
- }
282
- // else: neither a prior read nor a current row → leave undefined
283
- // so CAS misses and surfaces OCC.
284
- }
285
- }
286
- }
287
- // Prepare BEFORE the conflict scan: each write is a CAS against the row's
288
- // commit_ts, so any concurrent peer that touched the row produces a
289
- // 0-row-affected UPDATE/DELETE → OccConflictError. Inserts surface as
290
- // PK violations, also translated to OCC.
291
- async prepareCommitWrites(readSet, writeSet, commitTs) {
292
- const result = [];
293
- await this.pgPrepareBatchWrites(result, writeSet, commitTs);
294
- await this.pgPrepareCommitLogs(result, writeSet, commitTs);
295
- const hasRangeRead = readSet.some(r => r.kind === "range");
296
- if (!hasRangeRead) {
297
- const validateReads = this.pgPrepareValidatePointReadsByCommitTs(readSet, writeSet);
298
- return { commits: result, validateReadPoints: validateReads, hasRangeRead: false };
299
- }
300
- // has atleast one range read so use scan, prepare data
301
- const tableIds = [...new Set(readSet.map(r => r.tableId))];
302
- const rawReadSet = mapReadSetToRaw(readSet);
303
- return { commits: result, hasRangeRead: true, tableIds: tableIds, rawReadSet: rawReadSet };
304
- }
305
- ;
306
- async pgPrepareBatchWrites(result, writeSet, commitTs) {
307
- let i = 0;
308
- let counter = 0;
309
- while (i < writeSet.length) {
310
- counter++;
311
- const { table, op } = writeSet[i];
312
- let j = i + 1;
313
- while (j < writeSet.length && writeSet[j].table === table && writeSet[j].op === op)
314
- j++;
315
- const batch = writeSet.slice(i, j);
316
- if (op === "insert")
317
- await this.pgPrepareInsertBatch(result, table, batch, commitTs);
318
- else if (op === "update")
319
- await this.pgPrepareUpdateBatch(result, table, batch, commitTs);
320
- else
321
- await this.pgPrepareDeleteBatch(result, table, batch);
322
- i = j;
323
- if (counter % 50 == 0)
324
- await new Promise((resolve) => setImmediate(resolve));
325
- }
326
- }
327
- // N=1 INSERT fast-path: identical SQL shape to the original per-row
328
- // code, no unnest overhead. PG infers param types from the column
329
- // context directly, so we don't need pg_attribute either.
330
- pgPrepareInsertSingle(result, table, entry, commitTs) {
331
- const data = { ...(entry.data ?? {}), commit_ts: commitTs };
332
- const cols = Object.keys(data);
333
- const placeholders = cols.map((_, i) => `$${i + 1}`);
334
- result.push({
335
- sql: `INSERT INTO ${table} (${cols.join(", ")}) VALUES (${placeholders.join(", ")})`,
336
- params: Object.values(data),
337
- operation: "insertSingle",
338
- table: table,
339
- entryId: entry.id,
340
- });
341
- }
342
- async pgPrepareInsertBatch(result, table, entries, commitTs) {
343
- if (entries.length === 0)
344
- return;
345
- const types = this.getColumnTypes(table);
346
- if (!types) {
347
- this.logger.warn({ table }, "No column types found, falling back to non-batched writes for insert");
348
- let counter = 0;
349
- for (const entry of entries) {
350
- counter++;
351
- this.pgPrepareInsertSingle(result, table, entry, commitTs);
352
- if (counter % 50 == 0) {
353
- await new Promise((resolve) => setImmediate(resolve));
354
- }
355
- }
356
- return;
357
- }
358
- // Sub-bucket by sorted column signature: writes with different optional
359
- // columns can't share a statement.
360
- const groups = new Map();
361
- for (const entry of entries) {
362
- const cols = [...Object.keys(entry.data ?? {}), 'commit_ts'].sort();
363
- const key = cols.join("|");
364
- let g = groups.get(key);
365
- if (!g) {
366
- g = { cols, rows: [] };
367
- groups.set(key, g);
368
- }
369
- g.rows.push(entry);
370
- }
371
- let counter = 0;
372
- for (const { cols, rows } of groups.values()) {
373
- counter++;
374
- const sql = this.buildOrGetInsertSql(table, types, cols);
375
- // Params layout: one carrier array per column, each of length N.
376
- const params = cols.map(() => new Array(rows.length));
377
- for (let r = 0; r < rows.length; r++) {
378
- const data = { ...(rows[r].data ?? {}), commit_ts: commitTs };
379
- for (let c = 0; c < cols.length; c++) {
380
- const pgType = types.get(cols[c].toLowerCase()) ?? "text";
381
- params[c][r] = encodeForCarrier(data[cols[c]], pgType);
382
- }
383
- }
384
- if (counter % 50 == 0) {
385
- await new Promise((resolve) => setImmediate(resolve));
386
- }
387
- result.push({
388
- sql: sql,
389
- params: params,
390
- operation: "insertBatch",
391
- table: table,
392
- });
393
- }
394
- }
395
- // N=1 UPDATE fast-path: same shape as the original per-row CAS.
396
- pgPrepareUpdateSingle(result, table, entry, commitTs) {
397
- const data = { ...(entry.data ?? {}), commit_ts: commitTs };
398
- const cols = Object.keys(data);
399
- const sets = cols.map((col, i) => `${col} = $${i + 1}`);
400
- const values = Object.values(data);
401
- const idP = `$${values.length + 1}`;
402
- const tsP = `$${values.length + 2}`;
403
- const expected = entry.expectedCommitTs ?? -1n;
404
- result.push({
405
- sql: `UPDATE ${table} SET ${sets.join(", ")} WHERE id = ${idP} AND commit_ts = ${tsP}`,
406
- params: [...values, entry.id, expected],
407
- operation: "updateSingle",
408
- table: table,
409
- entryId: entry.id,
410
- expectedTs: expected,
411
- });
412
- }
413
- async pgPrepareUpdateBatch(result, table, entries, commitTs) {
414
- if (entries.length === 0)
415
- return;
416
- const types = this.getColumnTypes(table);
417
- if (!types) {
418
- this.logger.warn({ table }, "No column types found, falling back to non-batched writes for update");
419
- let counter = 0;
420
- for (const entry of entries) {
421
- counter++;
422
- this.pgPrepareUpdateSingle(result, table, entry, commitTs);
423
- if (counter % 50 == 0) {
424
- await new Promise((resolve) => setImmediate(resolve));
425
- }
426
- }
427
- return;
428
- }
429
- const groups = new Map();
430
- for (const entry of entries) {
431
- const cols = [...Object.keys(entry.data ?? {}), 'commit_ts'].sort();
432
- const key = cols.join("|");
433
- let g = groups.get(key);
434
- if (!g) {
435
- g = { cols, rows: [] };
436
- groups.set(key, g);
437
- }
438
- g.rows.push(entry);
439
- }
440
- let counter = 0;
441
- for (const { cols, rows } of groups.values()) {
442
- counter++;
443
- const sql = this.buildOrGetUpdateSql(table, types, cols);
444
- const idType = types.get("id") ?? "text";
445
- const idArr = new Array(rows.length);
446
- const expArr = new Array(rows.length);
447
- const colArrs = cols.map(() => new Array(rows.length));
448
- for (let r = 0; r < rows.length; r++) {
449
- const entry = rows[r];
450
- const data = { ...(entry.data ?? {}), commit_ts: commitTs };
451
- const expected = entry.expectedCommitTs ?? -1n;
452
- idArr[r] = encodeForCarrier(entry.id, idType);
453
- expArr[r] = encodeForCarrier(expected, "bigint");
454
- for (let c = 0; c < cols.length; c++) {
455
- const pgType = types.get(cols[c].toLowerCase()) ?? "text";
456
- colArrs[c][r] = encodeForCarrier(data[cols[c]], pgType);
457
- }
458
- }
459
- if (counter % 50 == 0) {
460
- await new Promise((resolve) => setImmediate(resolve));
461
- }
462
- const params = [idArr, expArr, ...colArrs];
463
- result.push({
464
- sql: sql,
465
- params: params,
466
- operation: "updateBatch",
467
- table: table,
468
- entries: rows,
469
- });
470
- }
471
- }
472
- // N=1 DELETE fast-path: original per-row CAS shape.
473
- pgPrepareDeleteSingle(result, table, entry) {
474
- const expected = entry.expectedCommitTs ?? -1n;
475
- result.push({
476
- sql: `DELETE FROM ${table} WHERE id = $1 AND commit_ts = $2`,
477
- params: [entry.id, expected],
478
- operation: "deleteSingle",
479
- table: table,
480
- entryId: entry.id,
481
- expectedTs: expected,
482
- });
483
- }
484
- async pgPrepareDeleteBatch(result, table, entries) {
485
- if (entries.length === 0)
486
- return;
487
- const types = this.getColumnTypes(table);
488
- if (!types) {
489
- this.logger.warn({ table }, "No column types found, falling back to non-batched writes for delete");
490
- let counter = 0;
491
- for (const entry of entries) {
492
- counter++;
493
- this.pgPrepareDeleteSingle(result, table, entry);
494
- if (counter % 50 == 0) {
495
- await new Promise((resolve) => setImmediate(resolve));
496
- }
497
- }
498
- return;
499
- }
500
- const sql = this.buildOrGetDeleteSql(table, types);
501
- const idType = types.get("id") ?? "text";
502
- const idArr = new Array(entries.length);
503
- const expArr = new Array(entries.length);
504
- let counter = 0;
505
- for (let i = 0; i < entries.length; i++) {
506
- counter++;
507
- const entry = entries[i];
508
- const expected = entry.expectedCommitTs ?? -1n;
509
- idArr[i] = encodeForCarrier(entry.id, idType);
510
- expArr[i] = encodeForCarrier(expected, "bigint");
511
- if (counter % 50 == 0) {
512
- await new Promise((resolve) => setImmediate(resolve));
513
- }
514
- }
515
- result.push({
516
- sql: sql,
517
- params: [idArr, expArr],
518
- operation: "deleteBatch",
519
- table: table,
520
- entries: entries,
521
- });
522
- }
523
- buildOrGetInsertSql(table, types, cols) {
524
- const key = `i:${table}:${cols.join(",")}`;
525
- let sql = this.sqlCache.get(key);
526
- if (sql)
527
- return sql;
528
- const placeholders = cols.map((c, i) => {
529
- const pgType = types.get(c.toLowerCase()) ?? "text";
530
- return `$${i + 1}::${pgCarrierType(pgType)}`;
531
- });
532
- const aliases = cols.map((_, i) => `c${i}`);
533
- const selectExprs = cols.map((c, i) => {
534
- const pgType = types.get(c.toLowerCase()) ?? "text";
535
- return pgSelectExpr(aliases[i], pgType);
536
- });
537
- sql = `INSERT INTO ${table} (${cols.join(", ")}) ` +
538
- `SELECT ${selectExprs.join(", ")} ` +
539
- `FROM unnest(${placeholders.join(", ")}) AS u(${aliases.join(", ")})`;
540
- this.sqlCache.set(key, sql);
541
- return sql;
542
- }
543
- buildOrGetUpdateSql(table, types, cols) {
544
- const key = `u:${table}:${cols.join(",")}`;
545
- let sql = this.sqlCache.get(key);
546
- if (sql)
547
- return sql;
548
- const idType = types.get("id") ?? "text";
549
- const idCarrier = `$1::${pgCarrierType(idType)}`;
550
- const expCarrier = `$2::bigint[]`;
551
- const dataCarriers = cols.map((c, i) => {
552
- const pgType = types.get(c.toLowerCase()) ?? "text";
553
- return `$${i + 3}::${pgCarrierType(pgType)}`;
554
- });
555
- const aliases = ["id_c", "exp_c", ...cols.map((_, i) => `c${i}`)];
556
- const setClause = cols
557
- .map((c, i) => {
558
- const pgType = types.get(c.toLowerCase()) ?? "text";
559
- return `${c} = ${pgSelectExpr(`u.${aliases[i + 2]}`, pgType)}`;
560
- })
561
- .join(", ");
562
- sql =
563
- `UPDATE ${table} AS t SET ${setClause} ` +
564
- `FROM unnest(${idCarrier}, ${expCarrier}, ${dataCarriers.join(", ")}) AS u(${aliases.join(", ")}) ` +
565
- `WHERE t.id = u.id_c AND t.commit_ts = u.exp_c ` +
566
- `RETURNING t.id AS id`;
567
- this.sqlCache.set(key, sql);
568
- return sql;
569
- }
570
- buildOrGetDeleteSql(table, types) {
571
- const key = `d:${table}`;
572
- let sql = this.sqlCache.get(key);
573
- if (sql)
574
- return sql;
575
- const idType = types.get("id") ?? "text";
576
- sql =
577
- `DELETE FROM ${table} AS t ` +
578
- `USING unnest($1::${pgCarrierType(idType)}, $2::bigint[]) AS u(id_c, exp_c) ` +
579
- `WHERE t.id = u.id_c AND t.commit_ts = u.exp_c ` +
580
- `RETURNING t.id AS id`;
581
- this.sqlCache.set(key, sql);
582
- return sql;
583
- }
584
- async pgPrepareCommitLogs(result, writeEntries, commitTs) {
585
- if (writeEntries.length === 0)
586
- return;
587
- // unnest with column-typed arrays: 4 placeholders regardless of N
588
- // (vs 4N for multi-row VALUES). All four columns have fixed driver-side
589
- // types so no per-table metadata lookup is needed.
590
- const ids = new Array(writeEntries.length);
591
- const tss = new Array(writeEntries.length);
592
- const tableIds = new Array(writeEntries.length);
593
- const datas = new Array(writeEntries.length);
594
- let counter = 0;
595
- for (let i = 0; i < writeEntries.length; i++) {
596
- counter++;
597
- const entry = writeEntries[i];
598
- ids[i] = toBufferView(normalizeIdToBytes(entry.id));
599
- tss[i] = commitTs;
600
- tableIds[i] = toBufferView(entry.tableId);
601
- datas[i] = toBufferView(utf8Encode(supaliveStringify(entry)));
602
- if (counter % 50 == 0) {
603
- await new Promise((resolve) => setImmediate(resolve));
604
- }
605
- }
606
- result.push({
607
- sql: `INSERT INTO commit_logs (id, ts, table_id, data) ` +
608
- `SELECT * FROM unnest($1::bytea[], $2::bigint[], $3::bytea[], $4::bytea[])`,
609
- params: [ids, tss, tableIds, datas],
610
- table: 'commit_logs',
611
- operation: 'commit_logs',
612
- });
613
- }
614
- /**
615
- * Point-only OCC check. For each genuine point read, compare the row's
616
- * current commit_ts against the value captured at read time. A `null`
617
- * expectedCommitTs means "row didn't exist when we read"; any row found
618
- * now is a conflict. Reads whose row is being written in this same txn
619
- * are skipped — the CAS UPDATE/DELETE already validated those.
620
- */
621
- pgPrepareValidatePointReadsByCommitTs(readSet, writeSet) {
622
- const result = [];
623
- // Skip reads whose row is being mutated in this same tx — CAS already
624
- // fenced them. We dedupe per (table, id) so duplicate reads of the same
625
- // row don't cause extra fetches.
626
- const writeKeys = new Set();
627
- for (const w of writeSet) {
628
- writeKeys.add(`${w.table} ${w.id}`);
629
- }
630
- const byTable = new Map();
631
- const seen = new Set();
632
- for (const entry of readSet) {
633
- if (entry.kind !== "point")
634
- continue;
635
- const key = `${entry.table} ${entry.id}`;
636
- if (writeKeys.has(key) || seen.has(key))
637
- continue;
638
- seen.add(key);
639
- // expectedCommitTs === undefined → no commit_ts captured at read time
640
- // (synthetic or pre-migration). Skip — we cannot CAS-validate it. This
641
- // path should only fire when callers construct PointReads externally.
642
- if (entry.expectedCommitTs === undefined)
643
- continue;
644
- const bucket = byTable.get(entry.table);
645
- if (bucket)
646
- bucket.push({ entry, key });
647
- else
648
- byTable.set(entry.table, [{ entry, key }]);
649
- }
650
- for (const [table, pendings] of byTable) {
651
- const ids = pendings.map((p) => p.entry.id);
652
- const placeholders = ids.map((_, i) => `$${i + 1}`).join(", ");
653
- result.push({
654
- sql: `SELECT id, commit_ts FROM ${table} WHERE id IN (${placeholders})`,
655
- params: ids,
656
- table: table,
657
- operation: "pointReadValidation",
658
- pendingsEntry: pendings,
659
- });
660
- }
661
- return result;
662
- }
663
- async commitWrites(beginTs, queries, commitTs) {
664
- await this.pgApplyWrites(queries.commits);
665
- // Fast path: when the readSet has no range reads, we don't need a
666
- // commit-log scan at all — point reads validate via per-row commit_ts,
667
- // and writes already self-validated via CAS above. This is the dominant
668
- // shape (most mutations target known ids) and eliminates the heaviest
669
- // OCC cost in the previous design.
670
- if (!queries.hasRangeRead) {
671
- if ((queries.validateReadPoints?.length ?? 0) > 0) {
672
- await this.validatePointReadsByCommitTs(queries.validateReadPoints ?? []);
673
- }
674
- return { success: true };
675
- }
676
- // Slow path: at least one range read present. Fall back to the
677
- // commit-log scan so phantom inserts and predicate transitions-in are
678
- // still caught. Point reads in the same set are covered by the same
679
- // scan, so no separate validation is needed.
680
- const logs = await this.getCommitLogsSinceTs(beginTs, commitTs, queries.tableIds ?? []);
681
- const hasConflict = checkLogsAffectReadSet(logs, queries.rawReadSet ?? []);
682
- if (hasConflict) {
683
- throw new OccConflictError(`Conflict detected: overlapping commit logs since ${beginTs}`, { logs });
684
- }
685
- return { success: true };
686
- }
687
- ;
688
- // Walks the writeSet in order and coalesces consecutive entries with the
689
- // same (table, op) into one batched statement. Order is preserved across
690
- // batch boundaries so replace-patterns (delete-then-insert of the same
691
- // id) behave identically to the per-row code path. Single-row batches
692
- // take a direct INSERT/UPDATE/DELETE fast-path to avoid the unnest+join
693
- // overhead, which matters under high contention.
694
- async pgApplyWrites(result) {
695
- for (const q of result) {
696
- switch (q.operation) {
697
- case 'insertSingle': {
698
- try {
699
- await this.query(q.sql, q.params);
700
- }
701
- catch (err) {
702
- if (err && typeof err === "object" && err.code === "23505") {
703
- throw new OccConflictError(`Conflict: insert into ${q.table}/${q.entryId} hit duplicate key`, err);
704
- }
705
- throw err;
706
- }
707
- break;
708
- }
709
- case 'insertBatch': {
710
- try {
711
- await this.query(q.sql, q.params);
712
- }
713
- catch (err) {
714
- if (err && typeof err === "object" && err.code === "23505") {
715
- const detail = err.detail;
716
- const idMatch = detail?.match(/Key \([^)]+\)=\(([^)]+)\)/);
717
- const failedId = idMatch?.[1];
718
- throw new OccConflictError(failedId !== undefined
719
- ? `Conflict: insert into ${q.table}/${failedId} hit duplicate key`
720
- : `Conflict: batch insert into ${q.table} hit duplicate key`, err);
721
- }
722
- throw err;
723
- }
724
- break;
725
- }
726
- case 'updateSingle': {
727
- const result = await this.query(q.sql, q.params);
728
- if ((result.rowCount ?? 0) === 0) {
729
- throw new OccConflictError(`Conflict: CAS update of ${q.table}/${q.entryId} (expected commit_ts=${q.expectedTs}) matched no rows`, { table: q.table, id: q.entryId, expectedTs: q.expectedTs });
730
- }
731
- break;
732
- }
733
- case 'updateBatch': {
734
- const result = await this.query(q.sql, q.params);
735
- const returned = new Set();
736
- for (const r of result.rows) {
737
- returned.add(r.id);
738
- }
739
- for (const entry of q.entries) {
740
- if (!returned.has(entry.id)) {
741
- const expected = entry.expectedCommitTs ?? -1n;
742
- throw new OccConflictError(`Conflict: CAS update of ${q.table}/${entry.id} (expected commit_ts=${expected}) matched no rows`, { table: q.table, id: q.entryId, expectedTs: q.expectedTs });
743
- }
744
- }
745
- break;
746
- }
747
- case 'deleteSingle': {
748
- const result = await this.query(q.sql, q.params);
749
- if ((result.rowCount ?? 0) === 0) {
750
- throw new OccConflictError(`Conflict: CAS delete of ${q.table}/${q.entryId} (expected commit_ts=${q.expectedTs}) matched no rows`, { table: q.table, id: q.entryId, expectedTs: q.expectedTs });
751
- }
752
- break;
753
- }
754
- case 'deleteBatch': {
755
- const result = await this.query(q.sql, q.params);
756
- const returned = new Set();
757
- for (const r of result.rows)
758
- returned.add(r.id);
759
- for (const entry of q.entries) {
760
- if (!returned.has(entry.id)) {
761
- const expected = entry.expectedCommitTs ?? -1n;
762
- throw new OccConflictError(`Conflict: CAS delete of ${q.table}/${entry.id} (expected commit_ts=${expected}) matched no rows`, { table: q.table, id: entry.id, expected });
763
- }
764
- }
765
- break;
766
- }
767
- case 'commit_logs': {
768
- await this.query(q.sql, q.params);
769
- break;
770
- }
771
- default:
772
- throw new Error("Unhandled Postgres Operation: " + q.operation);
773
- }
774
- }
775
- }
776
- /**
777
- * Point-only OCC check. For each genuine point read, compare the row's
778
- * current commit_ts against the value captured at read time. A `null`
779
- * expectedCommitTs means "row didn't exist when we read"; any row found
780
- * now is a conflict. Reads whose row is being written in this same txn
781
- * are skipped — the CAS UPDATE/DELETE already validated those.
782
- */
783
- async validatePointReadsByCommitTs(queries) {
784
- for (const q of queries) {
785
- const result = await this.query(q.sql, q.params);
786
- const currentById = new Map();
787
- for (const row of result.rows) {
788
- currentById.set(row.id, BigInt(row.commit_ts));
789
- }
790
- for (const { entry } of (q.pendingsEntry ?? [])) {
791
- const current = currentById.get(entry.id);
792
- const expected = entry.expectedCommitTs;
793
- if (expected === null) {
794
- // We read "no row"; conflict if a row now exists.
795
- if (current !== undefined) {
796
- throw new OccConflictError(`Conflict: row ${entry.table}/${entry.id} was inserted concurrently`, { table: entry.table, id: entry.id });
797
- }
798
- }
799
- else {
800
- if (current === undefined) {
801
- throw new OccConflictError(`Conflict: row ${entry.table}/${entry.id} was deleted concurrently`, { table: entry.table, id: entry.id, expected });
802
- }
803
- if (current !== expected) {
804
- throw new OccConflictError(`Conflict: row ${entry.table}/${entry.id} changed from ts=${expected} to ts=${current}`, { table: entry.table, id: entry.id, expected, current });
805
- }
806
- }
807
- }
808
- }
809
- }
810
- async pruneCommitLogsBefore(ts) {
811
- const result = await this.query(`DELETE FROM commit_logs WHERE ts < $1`, [ts]);
812
- return BigInt(result.rowCount ?? 0);
813
- }
814
- async getCommitLogsBetweenTs(beginTs, endTs, tableIds) {
815
- if (tableIds.length === 0)
816
- return [];
817
- const placeholders = tableIds.map((_, i) => `$${i + 3}`).join(", ");
818
- const sql = `SELECT id, ts, table_id, data FROM commit_logs WHERE ts >= $1 AND ts < $2 AND table_id IN (${placeholders})`;
819
- const result = await this.query(sql, [beginTs, endTs, ...tableIds.map(toBufferView)]);
820
- return result.rows.map((row) => ({
821
- id: normalizeToBytes(row.id),
822
- ts: BigInt(row.ts),
823
- tableId: normalizeToBytes(row.table_id),
824
- data: normalizeToBytes(row.data),
825
- }));
826
- }
827
- async getCommitLogsSinceTs(sinceTs, excludeTs, tableIds) {
828
- if (tableIds.length === 0)
829
- return [];
830
- const placeholders = tableIds.map((_, i) => `$${i + 3}`).join(", ");
831
- const sql = `SELECT id, ts, table_id, data FROM commit_logs WHERE ts >= $1 AND ts <> $2 AND table_id IN (${placeholders})`;
832
- const result = await this.query(sql, [sinceTs, excludeTs, ...tableIds.map(toBufferView)]);
833
- return result.rows.map((row) => ({
834
- id: normalizeToBytes(row.id),
835
- ts: BigInt(row.ts),
836
- tableId: normalizeToBytes(row.table_id),
837
- data: normalizeToBytes(row.data),
838
- }));
839
- }
840
- async close() {
841
- await this.pool.end();
842
- }
843
- }
844
- export class PgTxDatabase extends PgDatabase {
845
- constructor(logger, config, pool, client) {
846
- super(logger, config, pool);
847
- this.client = client;
848
- }
849
- client;
850
- async query(sql, params = []) {
851
- try {
852
- const result = await this.client.query(sql, params);
853
- return {
854
- rows: result.rows,
855
- rowCount: result.rowCount,
856
- };
857
- }
858
- catch (err) {
859
- throw translatePgError(err);
860
- }
861
- }
862
- async begin() {
863
- await this.client.query("BEGIN");
864
- }
865
- async commit() {
866
- await this.client.query("COMMIT");
867
- }
868
- async rollback() {
869
- await this.client.query("ROLLBACK");
870
- }
871
- release() {
872
- this.client.release();
873
- }
874
- }
875
- /**
876
- * Returns the unnest carrier array type for a column of `pgType`.
877
- * Scalars ride as their own array type so PG infers cleanly without an
878
- * extra cast on the SELECT/SET side. Array and jsonb columns can't ride
879
- * as their own array type (ragged 2D arrays aren't permitted, and the
880
- * driver can't tag jsonb sub-elements correctly inside a typed array),
881
- * so they get the universal `text[]` carrier with an explicit cast in
882
- * the SELECT/SET clause. Bytea rides natively as `bytea[]`.
883
- */
884
- function pgCarrierType(pgType) {
885
- const base = pgType.trim().toLowerCase();
886
- if (base === "bytea")
887
- return "bytea[]";
888
- if (base === "jsonb" || base === "json")
889
- return "text[]";
890
- if (base.endsWith("[]"))
891
- return "text[]";
892
- return `${pgType}[]`;
893
- }
894
- /** SELECT/SET expression for an unnest alias of a column with `pgType`.
895
- * When the carrier matches the column type (scalar / bytea), no cast is
896
- * needed and the alias passes through. When the carrier is text[] (jsonb
897
- * or array column), we cast back to the declared type. */
898
- function pgSelectExpr(alias, pgType) {
899
- const base = pgType.trim().toLowerCase();
900
- if (base === "jsonb" || base === "json" || base.endsWith("[]")) {
901
- return `(${alias})::${pgType}`;
902
- }
903
- return alias;
904
- }
905
- /**
906
- * Encodes a JS runtime value for transport via the chosen unnest carrier.
907
- * For scalar columns the pg driver handles type coercion directly (bigint,
908
- * number, boolean, Date, etc. all serialize cleanly). For jsonb columns we
909
- * stringify; for array columns we build a PG array literal. For bytea
910
- * columns we forward the bytes.
911
- *
912
- * Codecs declared by the upstream schema layer run before this point, so
913
- * `value` is already in the driver-side shape for its declared column.
914
- */
915
- function encodeForCarrier(value, pgTypeBase) {
916
- if (value === null || value === undefined)
917
- return null;
918
- if (pgTypeBase === "bytea") {
919
- return toBufferView(normalizeToBytes(value));
920
- }
921
- if (pgTypeBase === "jsonb" || pgTypeBase === "json") {
922
- return typeof value === "string" ? value : supaliveStringify(value);
923
- }
924
- if (pgTypeBase.endsWith("[]")) {
925
- if (!Array.isArray(value)) {
926
- throw new Error(`Expected array for column of type ${pgTypeBase}, got ${typeof value}`);
927
- }
928
- return pgArrayLiteral(value);
929
- }
930
- return value;
931
- }
932
- /** Serializes a JS array to a Postgres array literal `{a,b,c}`. Strings
933
- * are double-quoted with backslash/quote escaping; primitives stringify
934
- * directly; null becomes the literal NULL. */
935
- function pgArrayLiteral(arr) {
936
- const parts = new Array(arr.length);
937
- for (let i = 0; i < arr.length; i++) {
938
- const v = arr[i];
939
- if (v === null || v === undefined) {
940
- parts[i] = "NULL";
941
- }
942
- else if (typeof v === "string") {
943
- parts[i] = `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
944
- }
945
- else if (typeof v === "number" || typeof v === "bigint" || typeof v === "boolean") {
946
- parts[i] = String(v);
947
- }
948
- else {
949
- const s = supaliveStringify(v);
950
- parts[i] = `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
951
- }
952
- }
953
- return `{${parts.join(",")}}`;
954
- }
955
- /**
956
- * Postgres deadlock_detected (40P01) is transient and semantically equivalent
957
- * to an OCC conflict from the mutation runner's perspective: re-execute the
958
- * handler with a fresh snapshot. Wrapping here lets realtime_db.ts stay
959
- * driver-agnostic — its retry loop already handles OccConflictError.
960
- */
961
- function translatePgError(err) {
962
- if (err && typeof err === "object" && err.code === "40P01") {
963
- return new OccConflictError("deadlock detected", err);
964
- }
965
- return err;
966
- }
967
- // PG: when a sub-path is set, scope the target via `jsonb_path_query_first`
968
- // (returns the value at the path, or NULL → falsy in WHERE) before the
969
- // containment / array-membership check. Cast bound text to jsonb so the
970
- // same code path works for both `json` and `jsonb` columns.
971
- function pgScopedTarget(col, pathIndex) {
972
- return pathIndex === undefined
973
- ? col
974
- : `jsonb_path_query_first(${col}::jsonb, $${pathIndex}::jsonpath)`;
975
- }
976
- export const pgSql = {
977
- placeholder: (i) => `$${i}`,
978
- like: (col, i) => `${col} LIKE $${i}`,
979
- ilike: (col, i) => `${col} ILIKE $${i}`,
980
- icontains: (col, i) => `${col} ILIKE $${i}`,
981
- jsonContains: (col, i, opts) => `${pgScopedTarget(col, opts?.pathIndex)} @> $${i}::jsonb`,
982
- jsonContainedBy: (col, i, opts) => `${pgScopedTarget(col, opts?.pathIndex)} <@ $${i}::jsonb`,
983
- jsonHasKey: (col, i, opts) => {
984
- if (opts?.pathCount && opts.pathCount > 0) {
985
- const joiner = opts.mode === "all" ? " AND " : " OR ";
986
- const parts = [];
987
- for (let k = 0; k < opts.pathCount; k++) {
988
- parts.push(`jsonb_path_exists(${col}, $${i + k}::jsonpath)`);
989
- }
990
- return `(${parts.join(joiner)})`;
991
- }
992
- return opts?.raw
993
- ? `jsonb_path_exists(${col}, $${i}::jsonpath)`
994
- : `${col} ? $${i}`;
995
- },
996
- jsonArrayContains: (col, i, opts) => `${pgScopedTarget(col, opts?.pathIndex)} @> jsonb_build_array($${i}::jsonb)`,
997
- };
998
- //# sourceMappingURL=postgres.js.map