joist-orm 2.3.0-next.41 → 2.3.0-next.43

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 (95) hide show
  1. package/build/_virtual/_rolldown/runtime.cjs +23 -0
  2. package/build/_virtual/_rolldown/runtime.js +5 -0
  3. package/build/codegen.cjs +19 -0
  4. package/build/codegen.cjs.map +1 -0
  5. package/build/codegen.d.cts +1 -0
  6. package/build/codegen.d.mts +1 -0
  7. package/build/codegen.js +11 -28
  8. package/build/codegen.js.map +1 -1
  9. package/build/drivers/PostgresDriver.cjs +290 -0
  10. package/build/drivers/PostgresDriver.cjs.map +1 -0
  11. package/build/drivers/PostgresDriver.d.cts +77 -0
  12. package/build/drivers/PostgresDriver.d.cts.map +1 -0
  13. package/build/drivers/PostgresDriver.d.mts +77 -0
  14. package/build/drivers/PostgresDriver.d.mts.map +1 -0
  15. package/build/drivers/PostgresDriver.js +249 -325
  16. package/build/drivers/PostgresDriver.js.map +1 -1
  17. package/build/drivers/WireRowData.cjs +374 -0
  18. package/build/drivers/WireRowData.cjs.map +1 -0
  19. package/build/drivers/WireRowData.d.cts +111 -0
  20. package/build/drivers/WireRowData.d.cts.map +1 -0
  21. package/build/drivers/WireRowData.d.mts +111 -0
  22. package/build/drivers/WireRowData.d.mts.map +1 -0
  23. package/build/drivers/WireRowData.js +352 -412
  24. package/build/drivers/WireRowData.js.map +1 -1
  25. package/build/drivers/binaryParsers.cjs +422 -0
  26. package/build/drivers/binaryParsers.cjs.map +1 -0
  27. package/build/drivers/{binaryParsers.d.ts → binaryParsers.d.cts} +18 -14
  28. package/build/drivers/binaryParsers.d.cts.map +1 -0
  29. package/build/drivers/binaryParsers.d.mts +80 -0
  30. package/build/drivers/binaryParsers.d.mts.map +1 -0
  31. package/build/drivers/binaryParsers.js +315 -385
  32. package/build/drivers/binaryParsers.js.map +1 -1
  33. package/build/drivers/patchPgProtocol.cjs +255 -0
  34. package/build/drivers/patchPgProtocol.cjs.map +1 -0
  35. package/build/drivers/{patchPgProtocol.d.ts → patchPgProtocol.d.cts} +18 -14
  36. package/build/drivers/patchPgProtocol.d.cts.map +1 -0
  37. package/build/drivers/patchPgProtocol.d.mts +47 -0
  38. package/build/drivers/patchPgProtocol.d.mts.map +1 -0
  39. package/build/drivers/patchPgProtocol.js +211 -232
  40. package/build/drivers/patchPgProtocol.js.map +1 -1
  41. package/build/graphql-codegen-export.cjs +10 -0
  42. package/build/graphql-codegen-export.d.cts +1 -0
  43. package/build/graphql-codegen-export.d.mts +1 -0
  44. package/build/graphql-codegen-export.js +2 -19
  45. package/build/graphql-export.cjs +10 -0
  46. package/build/graphql-export.d.cts +1 -0
  47. package/build/graphql-export.d.mts +1 -0
  48. package/build/graphql-export.js +2 -19
  49. package/build/index.cjs +41 -0
  50. package/build/index.d.cts +3 -0
  51. package/build/index.d.mts +3 -0
  52. package/build/index.js +3 -27
  53. package/build/knex-export.cjs +10 -0
  54. package/build/knex-export.d.cts +1 -0
  55. package/build/knex-export.d.mts +1 -0
  56. package/build/knex-export.js +2 -19
  57. package/build/pg-export.cjs +13 -0
  58. package/build/pg-export.d.cts +4 -0
  59. package/build/pg-export.d.mts +4 -0
  60. package/build/pg-export.js +4 -20
  61. package/build/pg-migrate.cjs +22 -0
  62. package/build/pg-migrate.cjs.map +1 -0
  63. package/build/pg-migrate.d.cts +1 -0
  64. package/build/pg-migrate.d.mts +1 -0
  65. package/build/pg-migrate.js +13 -28
  66. package/build/pg-migrate.js.map +1 -1
  67. package/build/seed.cjs +49 -0
  68. package/build/seed.cjs.map +1 -0
  69. package/build/{seed.d.ts → seed.d.cts} +5 -1
  70. package/build/seed.d.cts.map +1 -0
  71. package/build/seed.d.mts +24 -0
  72. package/build/seed.d.mts.map +1 -0
  73. package/build/seed.js +39 -45
  74. package/build/seed.js.map +1 -1
  75. package/build/tests-export.cjs +10 -0
  76. package/build/tests-export.d.cts +1 -0
  77. package/build/tests-export.d.mts +1 -0
  78. package/build/tests-export.js +2 -19
  79. package/package.json +150 -28
  80. package/build/codegen.d.ts +0 -2
  81. package/build/drivers/PostgresDriver.d.ts +0 -73
  82. package/build/drivers/WireRowData.d.ts +0 -107
  83. package/build/graphql-codegen-export.d.ts +0 -1
  84. package/build/graphql-codegen-export.js.map +0 -1
  85. package/build/graphql-export.d.ts +0 -1
  86. package/build/graphql-export.js.map +0 -1
  87. package/build/index.d.ts +0 -3
  88. package/build/index.js.map +0 -1
  89. package/build/knex-export.d.ts +0 -1
  90. package/build/knex-export.js.map +0 -1
  91. package/build/pg-export.d.ts +0 -3
  92. package/build/pg-export.js.map +0 -1
  93. package/build/pg-migrate.d.ts +0 -2
  94. package/build/tests-export.d.ts +0 -1
  95. package/build/tests-export.js.map +0 -1
@@ -1,430 +1,370 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.WireRowData = void 0;
7
- exports.executeRowDataQuery = executeRowDataQuery;
8
- exports.isRowDataCapableClient = isRowDataCapableClient;
9
- const pg_1 = __importDefault(require("pg"));
10
- const binaryParsers_1 = require("./binaryParsers");
11
- // pg's internal-but-exported Query class; subclassing it reuses its extended-protocol
12
- // submit/bind logic while letting us intercept row handling (the same seam pg-cursor uses).
13
- // eslint-disable-next-line @typescript-eslint/no-require-imports
14
- const PgQuery = require("pg/lib/query");
15
- // Each row is three consecutive entries in the `#rowSlices` table: chunk index, start, length
1
+ import { getBinaryTypeParser } from "./binaryParsers.js";
2
+ import { createRequire } from "node:module";
3
+ import "joist-core";
4
+ import pg from "pg";
5
+ //#region src/drivers/WireRowData.ts
6
+ const PgQuery = createRequire(import.meta.url)("pg/lib/query");
16
7
  const ROW_STRIDE = 3;
17
8
  /** The row-length sentinel for rows dropped by `finalize` compaction. */
18
- const DROPPED = 0xffffffff;
9
+ const DROPPED = 4294967295;
19
10
  /** Only compact when dropped rows hold more than this fraction of the payload bytes. */
20
- const COMPACT_THRESHOLD = 0.2;
11
+ const COMPACT_THRESHOLD = .2;
21
12
  /**
22
- * Only allocate the adaptive scan cursor once a fault targets at least this ordinal: shallower
23
- * scans cost less than the cursor's bookkeeping, i.e. hydrate-only results (id is ordinal ~0)
24
- * and narrow reads never pay the 6 bytes/row.
25
- */
13
+ * Only allocate the adaptive scan cursor once a fault targets at least this ordinal: shallower
14
+ * scans cost less than the cursor's bookkeeping, i.e. hydrate-only results (id is ordinal ~0)
15
+ * and narrow reads never pay the 6 bytes/row.
16
+ */
26
17
  const SCAN_CURSOR_MIN_ORDINAL = 8;
27
18
  /**
28
- * A lazy wire-row {@link RowData} over raw Postgres `DataRow` payload bytes.
29
- *
30
- * Each query produces its own `WireRowData` — one query, one result; results are never combined
31
- * or appended across queries, and the payload is read-only after the query completes (entity
32
- * mutations go into `InstanceData.data`, never back into the row bytes).
33
- *
34
- * Rows are kept in their row-major wire format (`int16 fieldCount` + length-prefixed cells) as
35
- * zero-copy views over the parser's own immutable buffers: `addRow` records each DataRow's
36
- * `(bytes, offset, length)` by reference, deduping the socket chunk that consecutive rows
37
- * share. (Our pg-protocol patch guarantees message bytes are never rewritten — chunks parse in
38
- * place and straddling messages get a buffer of their own; see `patchPgProtocol.ts`.) A
39
- * `row × column` cell is decoded on first field access by scanning the row's length-prefixed
40
- * cells to the column's ordinal ("row-lazy" decode, see JS-ROW-STORE-DESIGN.md §3/C2). This is
41
- * deferred decoding of row-major data, not a columnar layout.
42
- *
43
- * Because chunks are whole socket reads, retaining any row pins its ~64KiB chunk (plus whatever
44
- * protocol frames share it); `finalize`'s compaction copies retained rows out into an
45
- * exact-size owned buffer when enough of the payload was dropped.
46
- *
47
- * Text-format cells go through the same active text parsers node-postgres would resolve for
48
- * the query (i.e. honoring pool/client `TypeOverrides`), so they parse identically to classic
49
- * rows. Binary-format cells decode via `wireBinaryParser`: wire bytes -> value directly for
50
- * default-parsed scalar types (no intermediate string), else rendered to pg's canonical text
51
- * and fed through the active text parser for parity. (Classic node-postgres cannot do binary
52
- * at all — it round-trips cells through a UTF-8 string, corrupting bytes >= 0x80.)
53
- *
54
- * Because decoding is deferred, a custom parser that throws will do so on first field access
55
- * (or `toRow`/`toRows`), not while awaiting the query; `id` and inheritance-discriminator cells
56
- * still decode during hydration.
57
- *
58
- * After hydration, `finalize` trims unused capacity and, when some rows were not retained (i.e.
59
- * their entities were already in the identity map), compacts the payload down to only the
60
- * retained rows, so retained memory tracks live entities rather than query history.
61
- *
62
- * Small results deliberately stay lazy — there is NO row-count threshold below which we
63
- * materialize to a `PojoRowData` instead. Measured (benchmark-rowdata-small.ts, 40-col rows):
64
- * for the typical sparse access pattern (~6 of 40 columns read), keeping the lazy result wins at
65
- * every size including a single row (n=1: 4.3µs vs 7.6µs; n=1000: 0.76ms vs 3.0ms), because
66
- * materialization eagerly decodes every column while lazy faults only what is read. Even full
67
- * column coverage no longer flips the winner: with binary decode and `#readCell`'s adaptive
68
- * scan cursor, reading all ~36 columns of every row measures ~parity with classic end-to-end
69
- * (benchmark-lazy-parsing.ts), and small finds (n <= 10) are statistically identical.
70
- */
71
- class WireRowData {
72
- #chunks = [];
73
- /** Where each row's bytes live: `(chunk index, start, length)` slices, `ROW_STRIDE` entries per row. */
74
- #rowSlices = new Uint32Array(16 * ROW_STRIDE);
75
- #rowCount = 0;
76
- #payloadBytes = 0;
77
- #retained = undefined;
78
- #columns = new Map();
79
- #fields = [];
80
- /** The adaptive scan cursor: per row, the furthest scanned ordinal + its offset (see #readCell). */
81
- #scanOrdinal = undefined;
82
- #scanOffset = undefined;
83
- get rowCount() {
84
- return this.#rowCount;
85
- }
86
- /** The DataRow payload bytes currently indexed; drops when compaction discards rows. */
87
- get payloadBytes() {
88
- return this.#payloadBytes;
89
- }
90
- /** The bytes currently held by payload chunks + the row-slice table, i.e. for benchmarks. */
91
- get memoryBytes() {
92
- let bytes = this.#rowSlices.byteLength;
93
- for (const chunk of this.#chunks)
94
- bytes += chunk.length;
95
- return bytes;
96
- }
97
- get(rowIndex, columnName) {
98
- const column = this.#columns.get(columnName);
99
- // Tolerate probes for columns the query didn't select, i.e. `__class` on non-CTI queries
100
- if (column === undefined)
101
- return undefined;
102
- const base = this.#rowBase(rowIndex);
103
- const slices = this.#rowSlices;
104
- const start = slices[base + 1];
105
- return this.#readCell(this.#chunks[slices[base]], start, start + slices[base + 2], column, rowIndex);
106
- }
107
- /** Materializes one row as a POJO, i.e. for debugging and differential tests; values are not cached. */
108
- toRow(rowIndex) {
109
- const base = this.#rowBase(rowIndex);
110
- const slices = this.#rowSlices;
111
- const chunk = this.#chunks[slices[base]];
112
- const start = slices[base + 1];
113
- const end = start + slices[base + 2];
114
- const row = {};
115
- let pos = start + 2;
116
- for (const field of this.#fields) {
117
- const len = this.#cellLength(chunk, pos, end, rowIndex);
118
- pos += 4;
119
- if (len === -1) {
120
- row[field.name] = null;
121
- }
122
- else {
123
- row[field.name] = field.decode(chunk, pos, len);
124
- pos += len;
125
- }
126
- }
127
- return row;
128
- }
129
- /** Materializes classic POJO rows, i.e. for `afterFind` observation or debugging; not cached. */
130
- toRows() {
131
- const rows = new Array(this.#rowCount);
132
- for (let i = 0; i < this.#rowCount; i++)
133
- rows[i] = this.toRow(i);
134
- return rows;
135
- }
136
- /**
137
- * Resolves each column's decoder from the query's RowDescription.
138
- *
139
- * Binary-format fields decode through Joist's own binary registry only (see
140
- * `setBinaryTypeParser`) — `pg.types`/`TypeOverrides` are never consulted, and a column whose
141
- * oid has no registered binary parser fails the query here, before any rows arrive, rather
142
- * than guessing at a lossy decoding. Text-format fields (the `binary: false` escape hatch)
143
- * decode by utf8-slicing the cell into the active text parser, resolved via `getTypeParser`
144
- * (i.e. the pool/client `TypeOverrides` chain) or the global registry.
145
- */
146
- setRowDescription(fields, getTypeParser) {
147
- for (let i = 0; i < fields.length; i++) {
148
- const { name, dataTypeID, format } = fields[i];
149
- let decode;
150
- if (format === "binary") {
151
- decode = (0, binaryParsers_1.getBinaryTypeParser)(dataTypeID);
152
- if (decode === undefined) {
153
- throw new Error(`joist-orm: no binary type parser registered for oid ${dataTypeID} (column "${name}");` +
154
- ` register one with setBinaryTypeParser (i.e. binaryTextParser for text-like types)`);
155
- }
156
- }
157
- else {
158
- const parse = getTypeParser?.(dataTypeID) ?? pg_1.default.types.getTypeParser(dataTypeID, "text");
159
- decode = textCellDecoder(parse);
160
- }
161
- const column = { name, ordinal: i, decode };
162
- this.#columns.set(name, column);
163
- this.#fields.push(column);
164
- }
165
- }
166
- /**
167
- * Records one DataRow payload *by reference* (zero-copy); called synchronously from the wire
168
- * parser, whose patched buffer management guarantees the bytes are never rewritten.
169
- *
170
- * Consecutive rows usually share one socket chunk, so `bytes` is deduped against the last
171
- * chunk ref; retaining any row of a chunk pins the whole chunk, which `finalize`'s compaction
172
- * resolves by copying retained rows out when enough of the payload was dropped.
173
- */
174
- addRow(bytes, offset, payloadLength) {
175
- if (payloadLength < 2 || offset + payloadLength > bytes.length) {
176
- throw new Error(`Malformed DataRow payload (length ${payloadLength})`);
177
- }
178
- let chunkIndex = this.#chunks.length - 1;
179
- if (chunkIndex === -1 || this.#chunks[chunkIndex] !== bytes) {
180
- this.#chunks.push(bytes);
181
- chunkIndex++;
182
- }
183
- let slices = this.#rowSlices;
184
- const base = this.#rowCount * ROW_STRIDE;
185
- if (base === slices.length) {
186
- const grown = new Uint32Array(slices.length * 2);
187
- grown.set(slices);
188
- slices = this.#rowSlices = grown;
189
- }
190
- slices[base] = chunkIndex;
191
- slices[base + 1] = offset;
192
- slices[base + 2] = payloadLength;
193
- this.#rowCount++;
194
- this.#payloadBytes += payloadLength;
195
- }
196
- /** Marks `rowIndex` as retained by a hydrated entity; unmarked rows can be compacted away. */
197
- retain(rowIndex) {
198
- (this.#retained ??= []).push(rowIndex);
199
- }
200
- /**
201
- * Trims unused capacity, and compacts down to only `retain`-ed rows when enough rows were not
202
- * retained to be worth the copy.
203
- *
204
- * Compaction exists to release pinned socket buffers to the GC: rows are zero-copy views into
205
- * whole ~64KiB chunks, so keeping any row alive pins its entire chunk — and unretained rows
206
- * are typically duplicates whose entities were *already in memory* (identity-map hits that
207
- * keep their original `rowData`), making the newly-arrived bytes dead weight. Copying the
208
- * retained rows into one exact-size owned buffer lets every chunk reference drop.
209
- *
210
- * The copy re-copies every retained byte, so it only pays off when it buys back a meaningful
211
- * fraction of the payload: we compact when the dropped rows hold more than 20% of the payload
212
- * bytes, and otherwise just trim, accepting the (bounded) leftover bytes. Called once after
213
- * hydration + sidecar reads (`_tags`, preload aggregates) are complete; retained entities keep
214
- * their original `rowIndex`, and un-compacted unretained rows simply remain readable-but-unused.
215
- */
216
- finalize() {
217
- const retained = this.#retained ?? [];
218
- this.#retained = undefined;
219
- if (retained.length < this.#rowCount) {
220
- let retainedBytes = 0;
221
- for (const i of retained)
222
- retainedBytes += this.#rowSlices[i * ROW_STRIDE + 2];
223
- const droppedBytes = this.#payloadBytes - retainedBytes;
224
- if (droppedBytes > this.#payloadBytes * COMPACT_THRESHOLD) {
225
- this.#compact(retained, retainedBytes);
226
- return;
227
- }
228
- }
229
- // Just shrink the row-slice table to its used size
230
- if (this.#rowCount * ROW_STRIDE < this.#rowSlices.length) {
231
- this.#rowSlices = this.#rowSlices.slice(0, this.#rowCount * ROW_STRIDE);
232
- }
233
- }
234
- /** Copies retained rows into one owned buffer, releasing the pinned socket chunks to the GC. */
235
- #compact(retained, bytes) {
236
- const chunks = bytes > 0 ? [Buffer.allocUnsafe(bytes)] : [];
237
- const slices = new Uint32Array(this.#rowCount * ROW_STRIDE);
238
- for (let i = 0; i < this.#rowCount; i++)
239
- slices[i * ROW_STRIDE + 2] = DROPPED;
240
- let used = 0;
241
- for (const i of retained) {
242
- const base = i * ROW_STRIDE;
243
- const source = this.#chunks[this.#rowSlices[base]];
244
- const start = this.#rowSlices[base + 1];
245
- const len = this.#rowSlices[base + 2];
246
- source.copy(chunks[0], used, start, start + len);
247
- slices[base] = 0;
248
- slices[base + 1] = used;
249
- slices[base + 2] = len;
250
- used += len;
251
- }
252
- this.#chunks = chunks;
253
- this.#rowSlices = slices;
254
- this.#payloadBytes = used;
255
- // The scan cursor survives: its cached offsets are relative to each row's (copied) payload
256
- }
257
- /** Validates `rowIndex` and returns its base index into the `#rowSlices` table. */
258
- #rowBase(rowIndex) {
259
- if (!(rowIndex >= 0 && rowIndex < this.#rowCount)) {
260
- throw new Error(`Invalid rowIndex ${rowIndex} (rowCount ${this.#rowCount})`);
261
- }
262
- const base = rowIndex * ROW_STRIDE;
263
- if (this.#rowSlices[base + 2] === DROPPED) {
264
- throw new Error(`Row ${rowIndex} was compacted away (its entity was already loaded)`);
265
- }
266
- return base;
267
- }
268
- /**
269
- * Scans a row's cells to `column`'s ordinal and decodes it.
270
- *
271
- * An adaptive per-row scan cursor caches the furthest cell boundary already scanned — the
272
- * ordinal whose row-relative offset is known (`#scanOrdinal`/`#scanOffset`) — so ascending
273
- * reads resume instead of re-scanning from the row start: a dense in-order read of all C
274
- * columns costs one linear pass rather than O(C^2) length-prefix skips. Out-of-order
275
- * (descending) faults simply scan from the start, i.e. never worse than without the cursor.
276
- * The arrays are a fixed 6 bytes/row, allocated lazily on the first fault deep enough for
277
- * resuming to matter, and being row-relative stay valid across compaction.
278
- */
279
- #readCell(chunk, start, end, column, rowIndex) {
280
- const fieldCount = chunk.readInt16BE(start);
281
- const { ordinal } = column;
282
- if (ordinal >= fieldCount) {
283
- throw new Error(`Row ${rowIndex} has ${fieldCount} cells but column ${column.name} is #${column.ordinal}`);
284
- }
285
- let pos = start + 2;
286
- let c = 0;
287
- let scanOrdinal = this.#scanOrdinal;
288
- if (scanOrdinal === undefined && ordinal >= SCAN_CURSOR_MIN_ORDINAL) {
289
- scanOrdinal = this.#scanOrdinal = new Uint16Array(this.#rowCount);
290
- this.#scanOffset = new Uint32Array(this.#rowCount);
291
- }
292
- if (scanOrdinal !== undefined && rowIndex < scanOrdinal.length) {
293
- // 0 = unset: caching cell #0 would be pointless (it is always at offset 2), so any real
294
- // entry is the ordinal, >= 1, whose row-relative offset is in #scanOffset
295
- const known = scanOrdinal[rowIndex];
296
- if (known !== 0 && known <= ordinal) {
297
- c = known;
298
- pos = start + this.#scanOffset[rowIndex];
299
- }
300
- }
301
- for (; c < ordinal; c++) {
302
- const len = this.#cellLength(chunk, pos, end, rowIndex);
303
- pos += len > 0 ? len + 4 : 4;
304
- }
305
- const len = this.#cellLength(chunk, pos, end, rowIndex);
306
- if (scanOrdinal !== undefined && rowIndex < scanOrdinal.length && ordinal + 1 > scanOrdinal[rowIndex]) {
307
- // advance-only: after reading cell #ordinal we know where cell #ordinal+1 starts
308
- scanOrdinal[rowIndex] = ordinal + 1;
309
- this.#scanOffset[rowIndex] = pos + 4 + (len > 0 ? len : 0) - start;
310
- }
311
- if (len === -1)
312
- return null;
313
- return column.decode(chunk, pos + 4, len);
314
- }
315
- /** Reads + validates one cell's length prefix. */
316
- #cellLength(chunk, pos, end, rowIndex) {
317
- if (pos + 4 > end)
318
- throw new Error(`Truncated DataRow payload in row ${rowIndex}`);
319
- const len = chunk.readInt32BE(pos);
320
- if (len < -1 || (len > 0 && pos + 4 + len > end)) {
321
- throw new Error(`Malformed cell length ${len} in row ${rowIndex}`);
322
- }
323
- return len;
324
- }
325
- }
326
- exports.WireRowData = WireRowData;
19
+ * A lazy wire-row {@link RowData} over raw Postgres `DataRow` payload bytes.
20
+ *
21
+ * Each query produces its own `WireRowData` — one query, one result; results are never combined
22
+ * or appended across queries, and the payload is read-only after the query completes (entity
23
+ * mutations go into `InstanceData.data`, never back into the row bytes).
24
+ *
25
+ * Rows are kept in their row-major wire format (`int16 fieldCount` + length-prefixed cells) as
26
+ * zero-copy views over the parser's own immutable buffers: `addRow` records each DataRow's
27
+ * `(bytes, offset, length)` by reference, deduping the socket chunk that consecutive rows
28
+ * share. (Our pg-protocol patch guarantees message bytes are never rewritten — chunks parse in
29
+ * place and straddling messages get a buffer of their own; see `patchPgProtocol.ts`.) A
30
+ * `row × column` cell is decoded on first field access by scanning the row's length-prefixed
31
+ * cells to the column's ordinal ("row-lazy" decode, see JS-ROW-STORE-DESIGN.md §3/C2). This is
32
+ * deferred decoding of row-major data, not a columnar layout.
33
+ *
34
+ * Because chunks are whole socket reads, retaining any row pins its ~64KiB chunk (plus whatever
35
+ * protocol frames share it); `finalize`'s compaction copies retained rows out into an
36
+ * exact-size owned buffer when enough of the payload was dropped.
37
+ *
38
+ * Text-format cells go through the same active text parsers node-postgres would resolve for
39
+ * the query (i.e. honoring pool/client `TypeOverrides`), so they parse identically to classic
40
+ * rows. Binary-format cells decode via `wireBinaryParser`: wire bytes -> value directly for
41
+ * default-parsed scalar types (no intermediate string), else rendered to pg's canonical text
42
+ * and fed through the active text parser for parity. (Classic node-postgres cannot do binary
43
+ * at all — it round-trips cells through a UTF-8 string, corrupting bytes >= 0x80.)
44
+ *
45
+ * Because decoding is deferred, a custom parser that throws will do so on first field access
46
+ * (or `toRow`/`toRows`), not while awaiting the query; `id` and inheritance-discriminator cells
47
+ * still decode during hydration.
48
+ *
49
+ * After hydration, `finalize` trims unused capacity and, when some rows were not retained (i.e.
50
+ * their entities were already in the identity map), compacts the payload down to only the
51
+ * retained rows, so retained memory tracks live entities rather than query history.
52
+ *
53
+ * Small results deliberately stay lazy — there is NO row-count threshold below which we
54
+ * materialize to a `PojoRowData` instead. Measured (benchmark-rowdata-small.ts, 40-col rows):
55
+ * for the typical sparse access pattern (~6 of 40 columns read), keeping the lazy result wins at
56
+ * every size including a single row (n=1: 4.3µs vs 7.6µs; n=1000: 0.76ms vs 3.0ms), because
57
+ * materialization eagerly decodes every column while lazy faults only what is read. Even full
58
+ * column coverage no longer flips the winner: with binary decode and `#readCell`'s adaptive
59
+ * scan cursor, reading all ~36 columns of every row measures ~parity with classic end-to-end
60
+ * (benchmark-lazy-parsing.ts), and small finds (n <= 10) are statistically identical.
61
+ */
62
+ var WireRowData = class {
63
+ #chunks = [];
64
+ /** Where each row's bytes live: `(chunk index, start, length)` slices, `ROW_STRIDE` entries per row. */
65
+ #rowSlices = /* @__PURE__ */ new Uint32Array(48);
66
+ #rowCount = 0;
67
+ #payloadBytes = 0;
68
+ #retained = void 0;
69
+ #columns = /* @__PURE__ */ new Map();
70
+ #fields = [];
71
+ /** The adaptive scan cursor: per row, the furthest scanned ordinal + its offset (see #readCell). */
72
+ #scanOrdinal = void 0;
73
+ #scanOffset = void 0;
74
+ get rowCount() {
75
+ return this.#rowCount;
76
+ }
77
+ /** The DataRow payload bytes currently indexed; drops when compaction discards rows. */
78
+ get payloadBytes() {
79
+ return this.#payloadBytes;
80
+ }
81
+ /** The bytes currently held by payload chunks + the row-slice table, i.e. for benchmarks. */
82
+ get memoryBytes() {
83
+ let bytes = this.#rowSlices.byteLength;
84
+ for (const chunk of this.#chunks) bytes += chunk.length;
85
+ return bytes;
86
+ }
87
+ get(rowIndex, columnName) {
88
+ const column = this.#columns.get(columnName);
89
+ if (column === void 0) return void 0;
90
+ const base = this.#rowBase(rowIndex);
91
+ const slices = this.#rowSlices;
92
+ const start = slices[base + 1];
93
+ return this.#readCell(this.#chunks[slices[base]], start, start + slices[base + 2], column, rowIndex);
94
+ }
95
+ /** Materializes one row as a POJO, i.e. for debugging and differential tests; values are not cached. */
96
+ toRow(rowIndex) {
97
+ const base = this.#rowBase(rowIndex);
98
+ const slices = this.#rowSlices;
99
+ const chunk = this.#chunks[slices[base]];
100
+ const start = slices[base + 1];
101
+ const end = start + slices[base + 2];
102
+ const row = {};
103
+ let pos = start + 2;
104
+ for (const field of this.#fields) {
105
+ const len = this.#cellLength(chunk, pos, end, rowIndex);
106
+ pos += 4;
107
+ if (len === -1) row[field.name] = null;
108
+ else {
109
+ row[field.name] = field.decode(chunk, pos, len);
110
+ pos += len;
111
+ }
112
+ }
113
+ return row;
114
+ }
115
+ /** Materializes classic POJO rows, i.e. for `afterFind` observation or debugging; not cached. */
116
+ toRows() {
117
+ const rows = new Array(this.#rowCount);
118
+ for (let i = 0; i < this.#rowCount; i++) rows[i] = this.toRow(i);
119
+ return rows;
120
+ }
121
+ /**
122
+ * Resolves each column's decoder from the query's RowDescription.
123
+ *
124
+ * Binary-format fields decode through Joist's own binary registry only (see
125
+ * `setBinaryTypeParser`) — `pg.types`/`TypeOverrides` are never consulted, and a column whose
126
+ * oid has no registered binary parser fails the query here, before any rows arrive, rather
127
+ * than guessing at a lossy decoding. Text-format fields (the `binary: false` escape hatch)
128
+ * decode by utf8-slicing the cell into the active text parser, resolved via `getTypeParser`
129
+ * (i.e. the pool/client `TypeOverrides` chain) or the global registry.
130
+ */
131
+ setRowDescription(fields, getTypeParser) {
132
+ for (let i = 0; i < fields.length; i++) {
133
+ const { name, dataTypeID, format } = fields[i];
134
+ let decode;
135
+ if (format === "binary") {
136
+ decode = getBinaryTypeParser(dataTypeID);
137
+ if (decode === void 0) throw new Error(`joist-orm: no binary type parser registered for oid ${dataTypeID} (column "${name}"); register one with setBinaryTypeParser (i.e. binaryTextParser for text-like types)`);
138
+ } else decode = textCellDecoder(getTypeParser?.(dataTypeID) ?? pg.types.getTypeParser(dataTypeID, "text"));
139
+ const column = {
140
+ name,
141
+ ordinal: i,
142
+ decode
143
+ };
144
+ this.#columns.set(name, column);
145
+ this.#fields.push(column);
146
+ }
147
+ }
148
+ /**
149
+ * Records one DataRow payload *by reference* (zero-copy); called synchronously from the wire
150
+ * parser, whose patched buffer management guarantees the bytes are never rewritten.
151
+ *
152
+ * Consecutive rows usually share one socket chunk, so `bytes` is deduped against the last
153
+ * chunk ref; retaining any row of a chunk pins the whole chunk, which `finalize`'s compaction
154
+ * resolves by copying retained rows out when enough of the payload was dropped.
155
+ */
156
+ addRow(bytes, offset, payloadLength) {
157
+ if (payloadLength < 2 || offset + payloadLength > bytes.length) throw new Error(`Malformed DataRow payload (length ${payloadLength})`);
158
+ let chunkIndex = this.#chunks.length - 1;
159
+ if (chunkIndex === -1 || this.#chunks[chunkIndex] !== bytes) {
160
+ this.#chunks.push(bytes);
161
+ chunkIndex++;
162
+ }
163
+ let slices = this.#rowSlices;
164
+ const base = this.#rowCount * ROW_STRIDE;
165
+ if (base === slices.length) {
166
+ const grown = new Uint32Array(slices.length * 2);
167
+ grown.set(slices);
168
+ slices = this.#rowSlices = grown;
169
+ }
170
+ slices[base] = chunkIndex;
171
+ slices[base + 1] = offset;
172
+ slices[base + 2] = payloadLength;
173
+ this.#rowCount++;
174
+ this.#payloadBytes += payloadLength;
175
+ }
176
+ /** Marks `rowIndex` as retained by a hydrated entity; unmarked rows can be compacted away. */
177
+ retain(rowIndex) {
178
+ (this.#retained ??= []).push(rowIndex);
179
+ }
180
+ /**
181
+ * Trims unused capacity, and compacts down to only `retain`-ed rows when enough rows were not
182
+ * retained to be worth the copy.
183
+ *
184
+ * Compaction exists to release pinned socket buffers to the GC: rows are zero-copy views into
185
+ * whole ~64KiB chunks, so keeping any row alive pins its entire chunk — and unretained rows
186
+ * are typically duplicates whose entities were *already in memory* (identity-map hits that
187
+ * keep their original `rowData`), making the newly-arrived bytes dead weight. Copying the
188
+ * retained rows into one exact-size owned buffer lets every chunk reference drop.
189
+ *
190
+ * The copy re-copies every retained byte, so it only pays off when it buys back a meaningful
191
+ * fraction of the payload: we compact when the dropped rows hold more than 20% of the payload
192
+ * bytes, and otherwise just trim, accepting the (bounded) leftover bytes. Called once after
193
+ * hydration + sidecar reads (`_tags`, preload aggregates) are complete; retained entities keep
194
+ * their original `rowIndex`, and un-compacted unretained rows simply remain readable-but-unused.
195
+ */
196
+ finalize() {
197
+ const retained = this.#retained ?? [];
198
+ this.#retained = void 0;
199
+ if (retained.length < this.#rowCount) {
200
+ let retainedBytes = 0;
201
+ for (const i of retained) retainedBytes += this.#rowSlices[i * ROW_STRIDE + 2];
202
+ if (this.#payloadBytes - retainedBytes > this.#payloadBytes * COMPACT_THRESHOLD) {
203
+ this.#compact(retained, retainedBytes);
204
+ return;
205
+ }
206
+ }
207
+ if (this.#rowCount * ROW_STRIDE < this.#rowSlices.length) this.#rowSlices = this.#rowSlices.slice(0, this.#rowCount * ROW_STRIDE);
208
+ }
209
+ /** Copies retained rows into one owned buffer, releasing the pinned socket chunks to the GC. */
210
+ #compact(retained, bytes) {
211
+ const chunks = bytes > 0 ? [Buffer.allocUnsafe(bytes)] : [];
212
+ const slices = new Uint32Array(this.#rowCount * ROW_STRIDE);
213
+ for (let i = 0; i < this.#rowCount; i++) slices[i * ROW_STRIDE + 2] = DROPPED;
214
+ let used = 0;
215
+ for (const i of retained) {
216
+ const base = i * ROW_STRIDE;
217
+ const source = this.#chunks[this.#rowSlices[base]];
218
+ const start = this.#rowSlices[base + 1];
219
+ const len = this.#rowSlices[base + 2];
220
+ source.copy(chunks[0], used, start, start + len);
221
+ slices[base] = 0;
222
+ slices[base + 1] = used;
223
+ slices[base + 2] = len;
224
+ used += len;
225
+ }
226
+ this.#chunks = chunks;
227
+ this.#rowSlices = slices;
228
+ this.#payloadBytes = used;
229
+ }
230
+ /** Validates `rowIndex` and returns its base index into the `#rowSlices` table. */
231
+ #rowBase(rowIndex) {
232
+ if (!(rowIndex >= 0 && rowIndex < this.#rowCount)) throw new Error(`Invalid rowIndex ${rowIndex} (rowCount ${this.#rowCount})`);
233
+ const base = rowIndex * ROW_STRIDE;
234
+ if (this.#rowSlices[base + 2] === DROPPED) throw new Error(`Row ${rowIndex} was compacted away (its entity was already loaded)`);
235
+ return base;
236
+ }
237
+ /**
238
+ * Scans a row's cells to `column`'s ordinal and decodes it.
239
+ *
240
+ * An adaptive per-row scan cursor caches the furthest cell boundary already scanned — the
241
+ * ordinal whose row-relative offset is known (`#scanOrdinal`/`#scanOffset`) — so ascending
242
+ * reads resume instead of re-scanning from the row start: a dense in-order read of all C
243
+ * columns costs one linear pass rather than O(C^2) length-prefix skips. Out-of-order
244
+ * (descending) faults simply scan from the start, i.e. never worse than without the cursor.
245
+ * The arrays are a fixed 6 bytes/row, allocated lazily on the first fault deep enough for
246
+ * resuming to matter, and being row-relative stay valid across compaction.
247
+ */
248
+ #readCell(chunk, start, end, column, rowIndex) {
249
+ const fieldCount = chunk.readInt16BE(start);
250
+ const { ordinal } = column;
251
+ if (ordinal >= fieldCount) throw new Error(`Row ${rowIndex} has ${fieldCount} cells but column ${column.name} is #${column.ordinal}`);
252
+ let pos = start + 2;
253
+ let c = 0;
254
+ let scanOrdinal = this.#scanOrdinal;
255
+ if (scanOrdinal === void 0 && ordinal >= SCAN_CURSOR_MIN_ORDINAL) {
256
+ scanOrdinal = this.#scanOrdinal = new Uint16Array(this.#rowCount);
257
+ this.#scanOffset = new Uint32Array(this.#rowCount);
258
+ }
259
+ if (scanOrdinal !== void 0 && rowIndex < scanOrdinal.length) {
260
+ const known = scanOrdinal[rowIndex];
261
+ if (known !== 0 && known <= ordinal) {
262
+ c = known;
263
+ pos = start + this.#scanOffset[rowIndex];
264
+ }
265
+ }
266
+ for (; c < ordinal; c++) {
267
+ const len = this.#cellLength(chunk, pos, end, rowIndex);
268
+ pos += len > 0 ? len + 4 : 4;
269
+ }
270
+ const len = this.#cellLength(chunk, pos, end, rowIndex);
271
+ if (scanOrdinal !== void 0 && rowIndex < scanOrdinal.length && ordinal + 1 > scanOrdinal[rowIndex]) {
272
+ scanOrdinal[rowIndex] = ordinal + 1;
273
+ this.#scanOffset[rowIndex] = pos + 4 + (len > 0 ? len : 0) - start;
274
+ }
275
+ if (len === -1) return null;
276
+ return column.decode(chunk, pos + 4, len);
277
+ }
278
+ /** Reads + validates one cell's length prefix. */
279
+ #cellLength(chunk, pos, end, rowIndex) {
280
+ if (pos + 4 > end) throw new Error(`Truncated DataRow payload in row ${rowIndex}`);
281
+ const len = chunk.readInt32BE(pos);
282
+ if (len < -1 || len > 0 && pos + 4 + len > end) throw new Error(`Malformed cell length ${len} in row ${rowIndex}`);
283
+ return len;
284
+ }
285
+ };
327
286
  /**
328
- * Executes `sql` on an already-checked-out client, returning a {@link RowData} instead of
329
- * materialized POJO rows.
330
- *
331
- * Uses a `pg` Query subclass that records each DataRow's raw payload bytes into the result
332
- * (via the lazy DataRow message from `patchPgProtocol`) and never materializes per-cell
333
- * strings or per-row objects. If the client's connection turns out to use an unpatched
334
- * pg-protocol copy (i.e. the app's pool was built from a different `pg` install than the one
335
- * joist-orm patched), the query fails with a descriptive error — a misconfiguration any CI
336
- * build/smoketest will surface immediately, so we fail loudly rather than silently degrade.
337
- * The rows already streamed are discarded; the connection itself stays usable.
338
- *
339
- * By default the query requests *binary* result format (via the extended protocol), so scalar
340
- * cells decode wire-bytes -> value with no intermediate strings; see `wireBinaryParser` for the
341
- * parity strategy. Pass `binary: false` for classic text-format results, or set
342
- * `JOIST_LAZY_BINARY=0` to flip the default while the binary path is a prototype (i.e. for
343
- * A/B benchmarking or as an escape hatch).
344
- */
345
- // The prototype escape hatch for binary results, i.e. for A/B benchmarking; read once at load
287
+ * Executes `sql` on an already-checked-out client, returning a {@link RowData} instead of
288
+ * materialized POJO rows.
289
+ *
290
+ * Uses a `pg` Query subclass that records each DataRow's raw payload bytes into the result
291
+ * (via the lazy DataRow message from `patchPgProtocol`) and never materializes per-cell
292
+ * strings or per-row objects. If the client's connection turns out to use an unpatched
293
+ * pg-protocol copy (i.e. the app's pool was built from a different `pg` install than the one
294
+ * joist-orm patched), the query fails with a descriptive error — a misconfiguration any CI
295
+ * build/smoketest will surface immediately, so we fail loudly rather than silently degrade.
296
+ * The rows already streamed are discarded; the connection itself stays usable.
297
+ *
298
+ * By default the query requests *binary* result format (via the extended protocol), so scalar
299
+ * cells decode wire-bytes -> value with no intermediate strings; see `wireBinaryParser` for the
300
+ * parity strategy. Pass `binary: false` for classic text-format results, or set
301
+ * `JOIST_LAZY_BINARY=0` to flip the default while the binary path is a prototype (i.e. for
302
+ * A/B benchmarking or as an escape hatch).
303
+ */
346
304
  const BINARY_BY_DEFAULT = process.env.JOIST_LAZY_BINARY !== "0";
347
305
  function executeRowDataQuery(client, sql, bindings, opts) {
348
- const binary = opts?.binary ?? BINARY_BY_DEFAULT;
349
- return new Promise((resolve, reject) => {
350
- const query = new RowDataQuery(
351
- // Binary results require the extended protocol; `queryMode` forces it for bindings-less queries
352
- binary
353
- ? { text: sql, values: bindings, binary: true, queryMode: "extended" }
354
- : { text: sql, values: bindings }, (err) => {
355
- if (err)
356
- reject(err);
357
- else
358
- resolve(query.rowData);
359
- });
360
- client.query(query);
361
- });
306
+ const binary = opts?.binary ?? BINARY_BY_DEFAULT;
307
+ return new Promise((resolve, reject) => {
308
+ const query = new RowDataQuery(binary ? {
309
+ text: sql,
310
+ values: bindings,
311
+ binary: true,
312
+ queryMode: "extended"
313
+ } : {
314
+ text: sql,
315
+ values: bindings
316
+ }, (err) => {
317
+ if (err) reject(err);
318
+ else resolve(query.rowData);
319
+ });
320
+ client.query(query);
321
+ });
362
322
  }
363
323
  /** Returns whether `client` supports the lazy row-data query path, before submitting anything. */
364
324
  function isRowDataCapableClient(client) {
365
- // Require the pure-JS pg client (pg-native has no `connection` and different query internals)
366
- return (typeof client === "object" &&
367
- client !== null &&
368
- typeof client.query === "function" &&
369
- client.connection !== undefined &&
370
- client.native === undefined);
325
+ return typeof client === "object" && client !== null && typeof client.query === "function" && client.connection !== void 0 && client.native === void 0;
371
326
  }
372
327
  /** A pg Query that diverts DataRows into a {@link WireRowData} instead of a `Result`. */
373
- class RowDataQuery extends PgQuery {
374
- #wire = new WireRowData();
375
- constructor(config, callback) {
376
- super(config, undefined, callback);
377
- }
378
- /**
379
- * The query's result rows, i.e. once our callback has fired.
380
- *
381
- * If a small-result materialization threshold ever seems attractive, this is where it would
382
- * go — but see the "Small results deliberately stay lazy" note on {@link WireRowData}: lazy
383
- * won the measured comparison at every row count for sparse access, so no threshold exists.
384
- */
385
- get rowData() {
386
- return this.#wire;
387
- }
388
- handleRowDescription(msg) {
389
- if (this._canceledDueToError)
390
- return;
391
- try {
392
- super.handleRowDescription(msg);
393
- // Resolve active *text* parsers through the client's TypeOverrides chain (client.js
394
- // injects `_types` at submit time); we can't reuse `Result._parsers` because in binary
395
- // mode those resolve to pg's (broken) binary registry, while our binary decode path is
396
- // built on text-parser parity — see `wireBinaryParser`
397
- const types = this._result?._types;
398
- this.#wire.setRowDescription(msg.fields, types && ((oid) => types.getTypeParser(oid, "text")));
399
- }
400
- catch (err) {
401
- // Mirror pg's Query error containment: record + reject at ReadyForQuery, keeping the
402
- // connection's protocol state intact
403
- this._canceledDueToError = err;
404
- }
405
- }
406
- handleDataRow(msg) {
407
- if (this._canceledDueToError)
408
- return;
409
- try {
410
- if (msg.bytes === undefined) {
411
- // This connection's pg-protocol copy is unpatched (i.e. the pool came from a different
412
- // `pg` install than the one joist-orm patched), so lazyRows cannot work — fail loudly
413
- // rather than silently degrade; any CI build/smoketest will surface this immediately
414
- throw new Error("joist-orm: lazyRows is enabled, but this connection's pg-protocol emits classic" +
415
- " DataRows (likely a duplicate pg install); fix the install or disable lazyRows.");
416
- }
417
- // `msg.length` includes the int32 length field itself, so the payload is `length - 4`;
418
- // our patched message carries the real length from `handlePacket`'s argument
419
- this.#wire.addRow(msg.bytes, msg.offset, msg.length - 4);
420
- }
421
- catch (err) {
422
- this._canceledDueToError = err;
423
- }
424
- }
425
- }
328
+ var RowDataQuery = class extends PgQuery {
329
+ #wire = new WireRowData();
330
+ constructor(config, callback) {
331
+ super(config, void 0, callback);
332
+ }
333
+ /**
334
+ * The query's result rows, i.e. once our callback has fired.
335
+ *
336
+ * If a small-result materialization threshold ever seems attractive, this is where it would
337
+ * go — but see the "Small results deliberately stay lazy" note on {@link WireRowData}: lazy
338
+ * won the measured comparison at every row count for sparse access, so no threshold exists.
339
+ */
340
+ get rowData() {
341
+ return this.#wire;
342
+ }
343
+ handleRowDescription(msg) {
344
+ if (this._canceledDueToError) return;
345
+ try {
346
+ super.handleRowDescription(msg);
347
+ const types = this._result?._types;
348
+ this.#wire.setRowDescription(msg.fields, types && ((oid) => types.getTypeParser(oid, "text")));
349
+ } catch (err) {
350
+ this._canceledDueToError = err;
351
+ }
352
+ }
353
+ handleDataRow(msg) {
354
+ if (this._canceledDueToError) return;
355
+ try {
356
+ if (msg.bytes === void 0) throw new Error("joist-orm: lazyRows is enabled, but this connection's pg-protocol emits classic DataRows (likely a duplicate pg install); fix the install or disable lazyRows.");
357
+ this.#wire.addRow(msg.bytes, msg.offset, msg.length - 4);
358
+ } catch (err) {
359
+ this._canceledDueToError = err;
360
+ }
361
+ }
362
+ };
426
363
  /** Builds a text-format cell decoder: utf8-slice the cell bytes into the active text parser. */
427
364
  function textCellDecoder(parse) {
428
- return (chunk, start, length) => parse(chunk.toString("utf8", start, start + length));
365
+ return (chunk, start, length) => parse(chunk.toString("utf8", start, start + length));
429
366
  }
367
+ //#endregion
368
+ export { WireRowData, executeRowDataQuery, isRowDataCapableClient };
369
+
430
370
  //# sourceMappingURL=WireRowData.js.map