@mason-data/cli 0.0.0-stage → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +209 -2
- package/dist/src/api/error.js +18 -0
- package/dist/src/api/postgres/catalog-worker.js +92 -0
- package/dist/src/api/postgres/catalog.js +442 -0
- package/dist/src/api/postgres/encode.js +541 -0
- package/dist/src/api/postgres/explain.js +143 -0
- package/dist/src/api/postgres/listener.js +60 -0
- package/dist/src/api/postgres/parser-worker.js +58 -0
- package/dist/src/api/postgres/parser.js +121 -0
- package/dist/src/api/postgres/plain.js +33 -0
- package/dist/src/api/postgres/protocol.js +404 -0
- package/dist/src/api/postgres/schema.js +54 -0
- package/dist/src/api/postgres/server.js +1670 -0
- package/dist/src/api/postgres/translate.js +248 -0
- package/dist/src/api/request-log.js +50 -0
- package/dist/src/cdc/pg-wire.js +482 -0
- package/dist/src/cdc/pgoutput.js +107 -0
- package/dist/src/cdc/poll.js +67 -0
- package/dist/src/cdc/source.js +65 -0
- package/dist/src/cli/background.js +174 -0
- package/dist/src/cli/command.js +172 -0
- package/dist/src/cli/commands.js +1145 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/dev.js +576 -0
- package/dist/src/cli/doctor.js +257 -0
- package/dist/src/cli/guides.js +134 -0
- package/dist/src/cli/init.js +326 -0
- package/dist/src/cli/machine.js +157 -0
- package/dist/src/cli/main.js +83 -0
- package/dist/src/cli/pg-tls.js +100 -0
- package/dist/src/cli/pg-url.js +52 -0
- package/dist/src/cli/pin.js +36 -0
- package/dist/src/cli/ports.js +118 -0
- package/dist/src/cli/preflight.js +338 -0
- package/dist/src/cli/project.js +127 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +217 -0
- package/dist/src/cli/replication-checks.js +120 -0
- package/dist/src/cli/replication-mode.js +148 -0
- package/dist/src/cli/run-server.js +388 -0
- package/dist/src/cli/runs.js +120 -0
- package/dist/src/cli/security.js +86 -0
- package/dist/src/cli/self-signed-cert.js +64 -0
- package/dist/src/cli/serve.js +296 -0
- package/dist/src/cli/sources.js +562 -0
- package/dist/src/cli/state.js +58 -0
- package/dist/src/cli/style.js +187 -0
- package/dist/src/cli/supervise.js +248 -0
- package/dist/src/cli/sync.js +94 -0
- package/dist/src/cli/ui.js +107 -0
- package/dist/src/cli/user.js +229 -0
- package/dist/src/cli/version.js +23 -0
- package/dist/src/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/executor-worker.js +263 -0
- package/dist/src/duck/executor.js +526 -0
- package/dist/src/duck/explain.js +119 -0
- package/dist/src/duck/functions.js +764 -0
- package/dist/src/duck/gaps.js +89 -0
- package/dist/src/duck/jsonb.js +186 -0
- package/dist/src/duck/kept-route.js +219 -0
- package/dist/src/duck/kept.js +797 -0
- package/dist/src/duck/late.js +221 -0
- package/dist/src/duck/libc-order.js +190 -0
- package/dist/src/duck/pg-eval-worker.js +34 -0
- package/dist/src/duck/pg-eval.js +59 -0
- package/dist/src/duck/pgtree.js +338 -0
- package/dist/src/duck/plan.js +246 -0
- package/dist/src/duck/publication.js +207 -0
- package/dist/src/duck/requests.js +209 -0
- package/dist/src/duck/resolved.js +248 -0
- package/dist/src/duck/rewrite.js +4001 -0
- package/dist/src/duck/store.js +831 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2864 -0
- package/dist/src/duck/to-char.js +482 -0
- package/dist/src/duck/token.js +31 -0
- package/dist/src/duck/types.js +427 -0
- package/dist/src/duck/users.js +231 -0
- package/dist/src/duck/verified.js +995 -0
- package/dist/src/duck/views.js +150 -0
- package/dist/src/lib/database-url.js +74 -0
- package/dist/src/lib/did-you-mean.js +43 -0
- package/dist/src/lib/dotenv.js +72 -0
- package/dist/src/lib/exit-when-written.js +20 -0
- package/dist/src/lib/float-equal.js +69 -0
- package/dist/src/lib/http-json.js +22 -0
- package/dist/src/lib/log.js +161 -0
- package/dist/src/lib/pg-numeric.js +489 -0
- package/dist/src/lib/port.js +22 -0
- package/dist/src/lib/project-file.js +49 -0
- package/dist/src/lib/project-files.js +43 -0
- package/dist/src/lib/project-version.js +76 -0
- package/dist/src/lib/runnable.js +14 -0
- package/dist/src/lib/shell-word.js +4 -0
- package/dist/src/lib/timezone.js +236 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +65 -0
- package/dist/src/web/app.js +336 -0
- package/dist/src/web/index.html +39 -0
- package/package.json +22 -4
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOW A VALUE CROSSES THE POSTGRES WIRE: the `pg_type` OIDs of the types this API sends (src/api/postgres/schema.ts
|
|
3
|
+
* `PG_API_TYPES`) and ONE encoder per type, text and binary, held to what Postgres itself writes byte for byte by
|
|
4
|
+
* tests/postgres-api/encode.test.ts. A value arrives as Mason's Query
|
|
5
|
+
* API carries it in JSON — a number, a boolean, or a string: a 64-bit integer past 2^53 as decimal digits, a time as an
|
|
6
|
+
* ISO instant (`2026-09-24T23:58:01.412Z`), a date as `2026-09-24`, a UUID, text, a JSON column's text — and leaves as
|
|
7
|
+
* the bytes a client expects of that Postgres type. Nothing here reads a table, a schema or a socket; the server
|
|
8
|
+
* chooses the type from the catalog and the format from the client's Bind.
|
|
9
|
+
*
|
|
10
|
+
* THE RULES THIS EMBODIES:
|
|
11
|
+
* - A value the type cannot take THROWS, naming the value and the type (`1.5` for int8, `2026-02-30`, a UUID with a
|
|
12
|
+
* letter past f, a time with nanoseconds): a best effort would be a wrong number a client believes. A NULL is not
|
|
13
|
+
* an encoder's: the DataRow writes it as -1 (`encodeValue` answers null for it).
|
|
14
|
+
* - FLOATS ARE PRINTED AS POSTGRES PRINTS THEM, not as JavaScript does. Postgres's `float8out` (at the default
|
|
15
|
+
* `extra_float_digits = 1`) is its Ryu port, src/common/d2s.c: the SHORTEST decimal that reads back as the same
|
|
16
|
+
* double, in a `%g`-shaped layout — fixed notation when the decimal exponent is in [-4, 15) (`100`, `0.0001`,
|
|
17
|
+
* `123456789012345`), else `d[.ddd]e±XX` with the sign always written and at least two exponent digits (`1e+15`,
|
|
18
|
+
* `1e-05`, `5e-324`, `1.7976931348623157e+308`); `-0`, `NaN`, `Infinity`, `-Infinity`. JavaScript's `String(x)`
|
|
19
|
+
* has the same digits but another layout (`1e21` / `100000000000000000000` where Postgres writes `1e+21` / `1e+20`).
|
|
20
|
+
* One difference is in the DIGITS: Postgres compiles d2s.c without STRICTLY_SHORTEST, so its `acceptBounds` is
|
|
21
|
+
* false and a decimal sitting EXACTLY on the half-ulp boundary of the double is never printed, where JavaScript
|
|
22
|
+
* prints it when the mantissa is even (round-half-even reads it back). So `1e23` is `9.999999999999999e+22` in
|
|
23
|
+
* Postgres and `1.25e22` is `1.2499999999999999e+22` (verified on PGlite with the same 64 bits sent in binary).
|
|
24
|
+
* `shortestInside` below does what d2s.c does: the shortest decimal STRICTLY inside the interval, the closest one
|
|
25
|
+
* when two of that length are, with exact BigInt arithmetic. A `float4` is the same over the float's own interval
|
|
26
|
+
* with the fixed range [-4, 6) (f2s.c: FLT_DIG), its digits the shortest that read back as the same FLOAT, which
|
|
27
|
+
* JavaScript has no function for — the search runs from one digit.
|
|
28
|
+
* - A `timestamptz` is rendered in the SESSION'S ZONE (§4.6: `SET TimeZone` is the request's `timezone`), as
|
|
29
|
+
* `YYYY-MM-DD HH:MM:SS[.ffffff]±HH[:MM[:SS]]` with the fraction's trailing zeros trimmed and the offset spelled as
|
|
30
|
+
* Postgres spells it: hours alone when the minutes are zero (`+00`, `-04`), with the minutes otherwise
|
|
31
|
+
* (`+05:30`), and with the seconds for the local-mean-time offsets tzdata holds before standard time
|
|
32
|
+
* (`+05:53:28` for Kolkata in 1800). The zone's rules come from `Intl.DateTimeFormat` (ICU's copy of tzdata: no
|
|
33
|
+
* dependency), which agrees with Postgres to the second on every case the harness runs. A year past 9999 is
|
|
34
|
+
* written with five digits as Postgres writes it; a wall-clock year before 1 (an instant Postgres would print with
|
|
35
|
+
* ` BC`) is refused: no ClickHouse time reaches it.
|
|
36
|
+
* - A `numeric` is EXACT or it is not sent as one (src/lib/pg-numeric.ts decides which values are): it arrives as its
|
|
37
|
+
* decimal text, computed with Postgres's own scale and rounding, and leaves as `numeric_out` writes it (every digit
|
|
38
|
+
* of its scale, trailing zeros kept) or as `numeric_send`'s base-10000 groups. A fractional JavaScript number has no
|
|
39
|
+
* scale and is refused: a value read as a float is a `float8`.
|
|
40
|
+
* - A `timestamp` WITHOUT time zone (OID 1114) is sent for ONE thing: a bucket of a zone's wall clock
|
|
41
|
+
* (`date_trunc('<unit>', t AT TIME ZONE '<zone>')`, Cube's granularity; translate.ts `ResultColumn.wallClock`). No
|
|
42
|
+
* copied column is one. The server hands it the wall clock the body's bucket is in its zone (server.ts
|
|
43
|
+
* `wallClockOf`: `2026-10-08T00:00:00`, with no zone marker — a value with one is refused, it would be an instant),
|
|
44
|
+
* rendered as Postgres renders a timestamp, `YYYY-MM-DD HH:MM:SS[.ffffff]` with no offset, in every session zone
|
|
45
|
+
* alike; the binary is the wall clock's microseconds from 2000-01-01 as if it were UTC, which is how Postgres counts
|
|
46
|
+
* a timestamp (`timestamp_send`).
|
|
47
|
+
* - A `jsonb`'s text is sent AS STORED, and that is a DECLARED DIVERGENCE: Postgres's `jsonb` canonicalises its
|
|
48
|
+
* text on input (keys sorted shortest-first then bytewise, duplicates dropped, one space after `:` and `,`,
|
|
49
|
+
* an escape such as `\u00e9` written as the character, `1e2` written `100`; the number's own scale is kept, `2.50` stays `2.50`), so `$1::jsonb`
|
|
50
|
+
* answers `{"a": 2, "b": 1}` for `{"b":1,"a":2}`. A column that was `jsonb` at the source replicates in that
|
|
51
|
+
* canonical form already (Postgres wrote it), so it IS byte-equal; a `json` column's text is the application's
|
|
52
|
+
* own, and is sent as such — the harness asserts both: byte-equal for canonical text, and that non-canonical
|
|
53
|
+
* text is what Postgres would NOT write. The binary format is a version byte 0x01 and the text.
|
|
54
|
+
*/
|
|
55
|
+
import { Buffer } from "node:buffer";
|
|
56
|
+
import { numericBinary, numericOf, numericText, NumericError } from "../../lib/pg-numeric.js";
|
|
57
|
+
/** The real `pg_type` OIDs: a client maps them to its own types by number, never by name. */
|
|
58
|
+
export const OID = {
|
|
59
|
+
int2: { oid: 21, size: 2, arrayOid: 1005 },
|
|
60
|
+
int4: { oid: 23, size: 4, arrayOid: 1007 },
|
|
61
|
+
int8: { oid: 20, size: 8, arrayOid: 1016 },
|
|
62
|
+
float4: { oid: 700, size: 4, arrayOid: 1021 },
|
|
63
|
+
float8: { oid: 701, size: 8, arrayOid: 1022 },
|
|
64
|
+
numeric: { oid: 1700, size: -1, arrayOid: 1231 },
|
|
65
|
+
text: { oid: 25, size: -1, arrayOid: 1009 },
|
|
66
|
+
bool: { oid: 16, size: 1, arrayOid: 1000 },
|
|
67
|
+
uuid: { oid: 2950, size: 16, arrayOid: 2951 },
|
|
68
|
+
date: { oid: 1082, size: 4, arrayOid: 1182 },
|
|
69
|
+
timestamptz: { oid: 1184, size: 8, arrayOid: 1185 },
|
|
70
|
+
jsonb: { oid: 3802, size: -1, arrayOid: 3807 },
|
|
71
|
+
};
|
|
72
|
+
/** `timestamp` without time zone: sent for a wall-clock bucket alone (the docblock), never a column's or a parameter's type. */
|
|
73
|
+
export const TIMESTAMP = { oid: 1114, size: 8, arrayOid: 1115 };
|
|
74
|
+
/** the OID entry of a wire type */
|
|
75
|
+
export const wireOid = (type) => type === "timestamp" ? TIMESTAMP : OID[type];
|
|
76
|
+
/** The RowDescription entry of a result column of `type`, in the format the client asked for. */
|
|
77
|
+
export function fieldOf(name, type, format) {
|
|
78
|
+
const o = wireOid(type);
|
|
79
|
+
return { name, typeOid: o.oid, typeSize: o.size, typeModifier: -1, format };
|
|
80
|
+
}
|
|
81
|
+
/** A value the type cannot take. The message names both; the server answers it as an error on the statement. */
|
|
82
|
+
export class EncodeError extends Error {
|
|
83
|
+
/** the SQLSTATE Postgres raises for the same value (a timestamp past its range is 22008), else XX000: Mason's gap */
|
|
84
|
+
code;
|
|
85
|
+
constructor(message, code = "XX000") { super(message); this.code = code; }
|
|
86
|
+
}
|
|
87
|
+
const refuse = (type, value, why) => {
|
|
88
|
+
throw new EncodeError(`cannot send ${JSON.stringify(value)} as ${type}: ${why}`);
|
|
89
|
+
};
|
|
90
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
91
|
+
// Integers
|
|
92
|
+
const INT_RANGE = {
|
|
93
|
+
int2: [-(2n ** 15n), 2n ** 15n - 1n],
|
|
94
|
+
int4: [-(2n ** 31n), 2n ** 31n - 1n],
|
|
95
|
+
int8: [-(2n ** 63n), 2n ** 63n - 1n],
|
|
96
|
+
};
|
|
97
|
+
/** A number that is a safe integer, or a string of decimal digits (what the API carries past 2^53), within the type. */
|
|
98
|
+
function integerOf(type, value) {
|
|
99
|
+
let n;
|
|
100
|
+
if (typeof value === "number") {
|
|
101
|
+
if (!Number.isSafeInteger(value))
|
|
102
|
+
return refuse(type, value, "not an integer the number can hold exactly (past 2^53 it is carried as a string)");
|
|
103
|
+
n = BigInt(value);
|
|
104
|
+
}
|
|
105
|
+
else if (typeof value === "string" && /^[+-]?\d+$/.test(value)) {
|
|
106
|
+
n = BigInt(value);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
return refuse(type, value, "not an integer");
|
|
110
|
+
}
|
|
111
|
+
const [lo, hi] = INT_RANGE[type];
|
|
112
|
+
if (n < lo || n > hi)
|
|
113
|
+
return refuse(type, value, `out of range (${lo} to ${hi})`);
|
|
114
|
+
return n;
|
|
115
|
+
}
|
|
116
|
+
function decompose(x, kind) {
|
|
117
|
+
if (kind === "float8") {
|
|
118
|
+
const b = Buffer.alloc(8);
|
|
119
|
+
b.writeDoubleBE(x);
|
|
120
|
+
const bits = b.readBigUInt64BE();
|
|
121
|
+
const E = Number((bits >> 52n) & 0x7ffn);
|
|
122
|
+
const F = bits & ((1n << 52n) - 1n);
|
|
123
|
+
if (E === 0)
|
|
124
|
+
return { m: F, q: -1074, lowGap: 2n };
|
|
125
|
+
return { m: F | (1n << 52n), q: E - 1075, lowGap: F !== 0n || E <= 1 ? 2n : 1n };
|
|
126
|
+
}
|
|
127
|
+
const b = Buffer.alloc(4);
|
|
128
|
+
b.writeFloatBE(x);
|
|
129
|
+
const bits = b.readUInt32BE();
|
|
130
|
+
const E = (bits >>> 23) & 0xff;
|
|
131
|
+
const F = BigInt(bits & 0x7fffff);
|
|
132
|
+
if (E === 0)
|
|
133
|
+
return { m: F, q: -149, lowGap: 2n };
|
|
134
|
+
return { m: F | (1n << 23n), q: E - 150, lowGap: F !== 0n || E <= 1 ? 2n : 1n };
|
|
135
|
+
}
|
|
136
|
+
/** The sign of `D × 10^e − K × 2^s`, exactly. */
|
|
137
|
+
function cmpDecimalBinary(D, e, K, s) {
|
|
138
|
+
let a = D;
|
|
139
|
+
let b = K;
|
|
140
|
+
if (e >= 0)
|
|
141
|
+
a *= 10n ** BigInt(e);
|
|
142
|
+
else
|
|
143
|
+
b *= 10n ** BigInt(-e);
|
|
144
|
+
if (s >= 0)
|
|
145
|
+
b <<= BigInt(s);
|
|
146
|
+
else
|
|
147
|
+
a <<= BigInt(-s);
|
|
148
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
149
|
+
}
|
|
150
|
+
/** A decimal `D × 10^e` with D of `p` digits: the closest such to x (`toExponential` is correctly rounded), and the
|
|
151
|
+
* one on the other side of x, when x is not that decimal exactly. */
|
|
152
|
+
function candidatesAt(x, p, d) {
|
|
153
|
+
const s = x.toExponential(p - 1);
|
|
154
|
+
const m = /^(\d)(?:\.(\d+))?e([+-]\d+)$/.exec(s);
|
|
155
|
+
if (!m)
|
|
156
|
+
throw new Error(`toExponential gave ${JSON.stringify(s)} for ${x}`);
|
|
157
|
+
const digits = m[1] + (m[2] ?? "");
|
|
158
|
+
const e = Number(m[3]) - (p - 1);
|
|
159
|
+
const D = BigInt(digits);
|
|
160
|
+
const side = cmpDecimalBinary(D, e, d.m, d.q);
|
|
161
|
+
if (side === 0)
|
|
162
|
+
return [{ D, e }];
|
|
163
|
+
if (side < 0)
|
|
164
|
+
return [{ D, e }, { D: D + 1n, e }];
|
|
165
|
+
// D is above x. The p-digit decimal just below it is D - 1 at the same exponent, unless D is a power of ten:
|
|
166
|
+
// below 100 the p-digit grid is finer (99.9, not 99).
|
|
167
|
+
if (D === 10n ** BigInt(p - 1))
|
|
168
|
+
return [{ D: 10n ** BigInt(p) - 1n, e: e - 1 }, { D, e }];
|
|
169
|
+
return [{ D: D - 1n, e }, { D, e }];
|
|
170
|
+
}
|
|
171
|
+
/** The significant digits of JavaScript's own shortest spelling: where the search for a double starts, since the
|
|
172
|
+
* interval JavaScript searches contains Postgres's, so Postgres's answer is never shorter. */
|
|
173
|
+
function jsShortestLength(x) {
|
|
174
|
+
const s = String(x).replace(/^-/, "").replace(/e.*$/, "").replace(".", "").replace(/^0+/, "").replace(/0+$/, "");
|
|
175
|
+
return Math.max(1, s.length);
|
|
176
|
+
}
|
|
177
|
+
/** Postgres's digits for a finite, non-zero float: the shortest decimal strictly inside its rounding interval,
|
|
178
|
+
* the closest to it among those (ties to an even last digit), as `digits × 10^e` with no trailing zero. */
|
|
179
|
+
function shortestInside(x, kind) {
|
|
180
|
+
const abs = Math.abs(x);
|
|
181
|
+
const d = decompose(abs, kind);
|
|
182
|
+
const X = 4n * d.m;
|
|
183
|
+
const U = X + 2n;
|
|
184
|
+
const L = X - d.lowGap;
|
|
185
|
+
const inside = (c) => cmpDecimalBinary(c.D, c.e, L, d.q - 2) > 0 && cmpDecimalBinary(c.D, c.e, U, d.q - 2) < 0;
|
|
186
|
+
const pMax = kind === "float8" ? 17 : 9;
|
|
187
|
+
for (let p = kind === "float8" ? jsShortestLength(abs) : 1; p <= pMax; p++) {
|
|
188
|
+
const fits = candidatesAt(abs, p, d).filter(inside);
|
|
189
|
+
let pick;
|
|
190
|
+
if (fits.length === 1)
|
|
191
|
+
pick = fits[0];
|
|
192
|
+
else if (fits.length === 2) {
|
|
193
|
+
const [lo, hi] = fits;
|
|
194
|
+
// Which is closer: compare the candidates' midpoint with x (2x = X × 2^(q-1)); exact, so a tie is a tie.
|
|
195
|
+
const eMin = Math.min(lo.e, hi.e);
|
|
196
|
+
const sum = lo.D * 10n ** BigInt(lo.e - eMin) + hi.D * 10n ** BigInt(hi.e - eMin);
|
|
197
|
+
const mid = cmpDecimalBinary(sum, eMin, X, d.q - 1);
|
|
198
|
+
pick = mid < 0 ? hi : mid > 0 ? lo : (lo.D % 2n === 0n ? lo : hi);
|
|
199
|
+
}
|
|
200
|
+
if (pick !== undefined) {
|
|
201
|
+
let { D, e } = pick;
|
|
202
|
+
while (D % 10n === 0n) {
|
|
203
|
+
D /= 10n;
|
|
204
|
+
e++;
|
|
205
|
+
}
|
|
206
|
+
return { digits: D.toString(), e };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`no decimal of up to ${pMax} digits reads back as ${x}: the float decomposition is wrong`);
|
|
210
|
+
}
|
|
211
|
+
/** d2s.c's `to_chars`: fixed notation for a decimal exponent in [-4, DIG), else exponential with a signed,
|
|
212
|
+
* two-digit-minimum exponent. */
|
|
213
|
+
function floatLayout(neg, digits, e, kind) {
|
|
214
|
+
const olength = digits.length;
|
|
215
|
+
const exp = e + olength - 1;
|
|
216
|
+
const sign = neg ? "-" : "";
|
|
217
|
+
if (exp >= -4 && exp < (kind === "float8" ? 15 : 6)) {
|
|
218
|
+
if (exp < 0)
|
|
219
|
+
return `${sign}0.${"0".repeat(-exp - 1)}${digits}`;
|
|
220
|
+
if (olength <= exp + 1)
|
|
221
|
+
return `${sign}${digits}${"0".repeat(exp + 1 - olength)}`;
|
|
222
|
+
return `${sign}${digits.slice(0, exp + 1)}.${digits.slice(exp + 1)}`;
|
|
223
|
+
}
|
|
224
|
+
const mantissa = olength > 1 ? `${digits[0]}.${digits.slice(1)}` : digits;
|
|
225
|
+
const expAbs = String(Math.abs(exp)).padStart(2, "0");
|
|
226
|
+
return `${sign}${mantissa}e${exp < 0 ? "-" : "+"}${expAbs}`;
|
|
227
|
+
}
|
|
228
|
+
/** What Postgres prints for a float, under the default `extra_float_digits` (1 since Postgres 12). */
|
|
229
|
+
export function formatFloat(x, kind) {
|
|
230
|
+
if (Number.isNaN(x))
|
|
231
|
+
return "NaN";
|
|
232
|
+
if (x === Infinity)
|
|
233
|
+
return "Infinity";
|
|
234
|
+
if (x === -Infinity)
|
|
235
|
+
return "-Infinity";
|
|
236
|
+
if (x === 0)
|
|
237
|
+
return Object.is(x, -0) ? "-0" : "0";
|
|
238
|
+
const { digits, e } = shortestInside(x, kind);
|
|
239
|
+
return floatLayout(x < 0, digits, e, kind);
|
|
240
|
+
}
|
|
241
|
+
const FLOAT_TEXT = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
|
|
242
|
+
const FLOAT_SPECIAL = {
|
|
243
|
+
nan: NaN, inf: Infinity, "+inf": Infinity, "-inf": -Infinity, infinity: Infinity, "+infinity": Infinity, "-infinity": -Infinity,
|
|
244
|
+
};
|
|
245
|
+
/** A number, or a string Postgres's float input would read (digits, or NaN / Infinity in any case). A float4 is the
|
|
246
|
+
* value rounded to a float, as the column holds it. */
|
|
247
|
+
function floatOf(kind, value) {
|
|
248
|
+
let x;
|
|
249
|
+
if (typeof value === "number")
|
|
250
|
+
x = value;
|
|
251
|
+
else if (typeof value === "string" && FLOAT_TEXT.test(value))
|
|
252
|
+
x = Number(value);
|
|
253
|
+
else if (typeof value === "string" && value.toLowerCase() in FLOAT_SPECIAL)
|
|
254
|
+
x = FLOAT_SPECIAL[value.toLowerCase()];
|
|
255
|
+
else
|
|
256
|
+
return refuse(kind, value, "not a number");
|
|
257
|
+
return kind === "float4" ? Math.fround(x) : x;
|
|
258
|
+
}
|
|
259
|
+
/** a numeric's special values as numeric_out writes them, with numeric_send's sign word for each */
|
|
260
|
+
const NUMERIC_SPECIAL = new Map([["NaN", 0xC000], ["Infinity", 0xD000], ["-Infinity", 0xF000]]);
|
|
261
|
+
/** An exact numeric as the answer carries it — a decimal text (the value a program computed, src/lib/pg-numeric.ts, or a
|
|
262
|
+
* 64-bit integer as digits) or a safe integer — at the scale its text shows. */
|
|
263
|
+
function exactOf(value) {
|
|
264
|
+
try {
|
|
265
|
+
return numericOf(value);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
// numericOf throws exactly NumericError, naming the value: the encoder's refusal says the same
|
|
269
|
+
if (err instanceof NumericError)
|
|
270
|
+
return refuse("numeric", value, err.message);
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
275
|
+
// Dates and times. Postgres counts a date in days and a timestamptz in microseconds from 2000-01-01 (UTC).
|
|
276
|
+
const PG_EPOCH_MS = Date.UTC(2000, 0, 1);
|
|
277
|
+
const MS_PER_DAY = 86_400_000;
|
|
278
|
+
/** The UTC milliseconds of a wall clock, for any year (`Date.UTC` alone reads a year under 100 as 19xx). */
|
|
279
|
+
function utcMs(y, mo, d, h = 0, mi = 0, s = 0) {
|
|
280
|
+
const t = new Date(0);
|
|
281
|
+
t.setUTCFullYear(y, mo - 1, d);
|
|
282
|
+
t.setUTCHours(h, mi, s, 0);
|
|
283
|
+
return t.getTime();
|
|
284
|
+
}
|
|
285
|
+
/** Whether a wall clock is a real one: the components read back unchanged (no 31st of April, no 25th hour). */
|
|
286
|
+
function isRealClock(ms, y, mo, d, h, mi, s) {
|
|
287
|
+
const t = new Date(ms);
|
|
288
|
+
return t.getUTCFullYear() === y && t.getUTCMonth() === mo - 1 && t.getUTCDate() === d
|
|
289
|
+
&& t.getUTCHours() === h && t.getUTCMinutes() === mi && t.getUTCSeconds() === s;
|
|
290
|
+
}
|
|
291
|
+
/** the last year a JavaScript Date holds whole (Postgres's timestamps go to 294276; a year past 9999 is written with
|
|
292
|
+
* its digits, as Postgres writes it: `138921-05-09`) */
|
|
293
|
+
const LAST_YEAR = 275759;
|
|
294
|
+
const DATE_TEXT = /^(\d{4,6})-(\d{2})-(\d{2})$/;
|
|
295
|
+
function dateOf(value) {
|
|
296
|
+
const m = typeof value === "string" ? DATE_TEXT.exec(value) : null;
|
|
297
|
+
if (!m)
|
|
298
|
+
return refuse("date", value, "not a YYYY-MM-DD date");
|
|
299
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
300
|
+
if (y < 1)
|
|
301
|
+
return refuse("date", value, "there is no year 0");
|
|
302
|
+
if (y > LAST_YEAR)
|
|
303
|
+
return refuse("date", value, `after the year ${LAST_YEAR}, the last this server can write`);
|
|
304
|
+
const ms = utcMs(y, mo, d);
|
|
305
|
+
if (!isRealClock(ms, y, mo, d, 0, 0, 0))
|
|
306
|
+
return refuse("date", value, "not a day of the calendar");
|
|
307
|
+
return { text: value, days: (ms - PG_EPOCH_MS) / MS_PER_DAY };
|
|
308
|
+
}
|
|
309
|
+
const INSTANT_TEXT = /^(\d{4,6})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|\+00(?::?00)?)?$/;
|
|
310
|
+
/** An ISO instant as the API writes it (`2026-09-24T23:58:01.412Z`), as UTC milliseconds and the microseconds past
|
|
311
|
+
* the millisecond. A naive one, or one at `+00:00`, is UTC too: every time on this API is (§4.8). */
|
|
312
|
+
function instantOf(value) {
|
|
313
|
+
const m = typeof value === "string" ? INSTANT_TEXT.exec(value) : null;
|
|
314
|
+
if (!m)
|
|
315
|
+
return refuse("timestamptz", value, "not an ISO instant (YYYY-MM-DDTHH:MM:SS[.ffffff]Z)");
|
|
316
|
+
const [y, mo, d, h, mi, s] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])];
|
|
317
|
+
const frac = m[7] ?? "";
|
|
318
|
+
if (frac.length > 6)
|
|
319
|
+
return refuse("timestamptz", value, "finer than a microsecond, which Postgres cannot carry");
|
|
320
|
+
if (y < 1)
|
|
321
|
+
return refuse("timestamptz", value, "there is no year 0");
|
|
322
|
+
if (y > LAST_YEAR)
|
|
323
|
+
return refuse("timestamptz", value, `after the year ${LAST_YEAR}, the last this server can write`);
|
|
324
|
+
const ms = utcMs(y, mo, d, h, mi, s);
|
|
325
|
+
if (!isRealClock(ms, y, mo, d, h, mi, s))
|
|
326
|
+
return refuse("timestamptz", value, "not a time of the calendar");
|
|
327
|
+
const micros = Number(frac.padEnd(6, "0"));
|
|
328
|
+
return { ms: ms + Math.floor(micros / 1000), micros: micros % 1000 };
|
|
329
|
+
}
|
|
330
|
+
const WALL_CLOCK_TEXT = /^(\d{4,6})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?$/;
|
|
331
|
+
/** A wall clock with no zone marker (`2026-10-08T00:00:00`, what `wallClockOf` makes of a bucket), as the milliseconds
|
|
332
|
+
* it would be as UTC and the microseconds past the millisecond: a timestamp's own count. */
|
|
333
|
+
function wallClockOf(value) {
|
|
334
|
+
const m = typeof value === "string" ? WALL_CLOCK_TEXT.exec(value) : null;
|
|
335
|
+
if (!m)
|
|
336
|
+
return refuse("timestamp", value, "not a wall clock with no zone (YYYY-MM-DDTHH:MM:SS[.ffffff]); an instant is a timestamptz");
|
|
337
|
+
const [y, mo, d, h, mi, s] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])];
|
|
338
|
+
const frac = m[7] ?? "";
|
|
339
|
+
if (frac.length > 6)
|
|
340
|
+
return refuse("timestamp", value, "finer than a microsecond, which Postgres cannot carry");
|
|
341
|
+
if (y < 1)
|
|
342
|
+
return refuse("timestamp", value, "there is no year 0");
|
|
343
|
+
if (y > LAST_YEAR)
|
|
344
|
+
return refuse("timestamp", value, `after the year ${LAST_YEAR}, the last this server can write`);
|
|
345
|
+
const ms = utcMs(y, mo, d, h, mi, s);
|
|
346
|
+
if (!isRealClock(ms, y, mo, d, h, mi, s))
|
|
347
|
+
return refuse("timestamp", value, "not a time of the calendar");
|
|
348
|
+
const micros = Number(frac.padEnd(6, "0"));
|
|
349
|
+
return { ms: ms + Math.floor(micros / 1000), micros: micros % 1000 };
|
|
350
|
+
}
|
|
351
|
+
/** `YYYY-MM-DD HH:MM:SS[.ffffff]`: a timestamp as `EncodeDateTime` writes one under DateStyle ISO, no offset. */
|
|
352
|
+
function timestampText(value) {
|
|
353
|
+
const { ms, micros } = wallClockOf(value);
|
|
354
|
+
const t = new Date(ms);
|
|
355
|
+
const fraction = String((ms - Math.floor(ms / 1000) * 1000) * 1000 + micros).padStart(6, "0").replace(/0+$/, "");
|
|
356
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
357
|
+
return `${String(t.getUTCFullYear()).padStart(4, "0")}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())} ${pad(t.getUTCHours())}:${pad(t.getUTCMinutes())}:${pad(t.getUTCSeconds())}`
|
|
358
|
+
+ (fraction === "" ? "" : `.${fraction}`);
|
|
359
|
+
}
|
|
360
|
+
/** One formatter per zone: constructing `Intl.DateTimeFormat` costs milliseconds, formatting microseconds. */
|
|
361
|
+
const zoneFormatters = new Map();
|
|
362
|
+
function zoneFormatter(timeZone) {
|
|
363
|
+
let f = zoneFormatters.get(timeZone);
|
|
364
|
+
if (f === undefined) {
|
|
365
|
+
try {
|
|
366
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
367
|
+
timeZone, hourCycle: "h23", era: "short", year: "numeric", month: "2-digit", day: "2-digit",
|
|
368
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
// The one RangeError Intl throws here is an unknown zone name; the session should have refused it at SET.
|
|
373
|
+
if (err instanceof RangeError)
|
|
374
|
+
throw new EncodeError(`cannot render a timestamptz in time zone ${JSON.stringify(timeZone)}: ${err.message}`);
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
377
|
+
zoneFormatters.set(timeZone, f);
|
|
378
|
+
}
|
|
379
|
+
return f;
|
|
380
|
+
}
|
|
381
|
+
/** The wall clock of an instant in a zone, with the zone's offset at that instant in whole seconds (to the second:
|
|
382
|
+
* the local-mean-time offsets before standard time have them, and Postgres prints them). */
|
|
383
|
+
function wallClock(ms, timeZone) {
|
|
384
|
+
const parts = {};
|
|
385
|
+
for (const p of zoneFormatter(timeZone).formatToParts(new Date(ms)))
|
|
386
|
+
parts[p.type] = p.value;
|
|
387
|
+
const [y, mo, d, h, mi, s] = [Number(parts.year), Number(parts.month), Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second)];
|
|
388
|
+
const bc = parts.era === "BC";
|
|
389
|
+
const localSeconds = utcMs(bc ? 1 - y : y, mo, d, h, mi, s) / 1000;
|
|
390
|
+
return { y, mo, d, h, mi, s, bc, offsetSeconds: localSeconds - Math.floor(ms / 1000) };
|
|
391
|
+
}
|
|
392
|
+
/** `+00`, `-04`, `+05:30`, `+05:53:28`: as `EncodeDateTime` spells a zone offset under DateStyle ISO. */
|
|
393
|
+
function offsetText(offsetSeconds) {
|
|
394
|
+
const sign = offsetSeconds < 0 ? "-" : "+";
|
|
395
|
+
const abs = Math.abs(offsetSeconds);
|
|
396
|
+
const hh = String(Math.floor(abs / 3600)).padStart(2, "0");
|
|
397
|
+
const mm = Math.floor((abs % 3600) / 60);
|
|
398
|
+
const ss = abs % 60;
|
|
399
|
+
if (ss !== 0)
|
|
400
|
+
return `${sign}${hh}:${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`;
|
|
401
|
+
if (mm !== 0)
|
|
402
|
+
return `${sign}${hh}:${String(mm).padStart(2, "0")}`;
|
|
403
|
+
return `${sign}${hh}`;
|
|
404
|
+
}
|
|
405
|
+
function timestamptzText(value, session) {
|
|
406
|
+
const { ms, micros } = instantOf(value);
|
|
407
|
+
const w = wallClock(ms, session.timeZone);
|
|
408
|
+
if (w.bc)
|
|
409
|
+
return refuse("timestamptz", value, `before year 1 in time zone ${session.timeZone}`);
|
|
410
|
+
const fraction = String((ms - Math.floor(ms / 1000) * 1000) * 1000 + micros).padStart(6, "0").replace(/0+$/, "");
|
|
411
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
412
|
+
return `${String(w.y).padStart(4, "0")}-${pad(w.mo)}-${pad(w.d)} ${pad(w.h)}:${pad(w.mi)}:${pad(w.s)}`
|
|
413
|
+
+ (fraction === "" ? "" : `.${fraction}`) + offsetText(w.offsetSeconds);
|
|
414
|
+
}
|
|
415
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
416
|
+
// The rest
|
|
417
|
+
const UUID_TEXT = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
418
|
+
/** The canonical lower-case spelling, as `uuid_out` writes it whatever case came in. */
|
|
419
|
+
function uuidOf(value) {
|
|
420
|
+
if (typeof value !== "string" || !UUID_TEXT.test(value))
|
|
421
|
+
return refuse("uuid", value, "not a UUID (8-4-4-4-12 hex digits)");
|
|
422
|
+
return value.toLowerCase();
|
|
423
|
+
}
|
|
424
|
+
function boolOf(value) {
|
|
425
|
+
if (typeof value === "boolean")
|
|
426
|
+
return value;
|
|
427
|
+
if (value === 0 || value === 1)
|
|
428
|
+
return value === 1;
|
|
429
|
+
return refuse("bool", value, "not a boolean");
|
|
430
|
+
}
|
|
431
|
+
/** Postgres's text holds no NUL byte (`invalid byte sequence for encoding "UTF8": 0x00`); ClickHouse's String does. */
|
|
432
|
+
function textOf(value) {
|
|
433
|
+
if (typeof value !== "string")
|
|
434
|
+
return refuse("text", value, "not a string");
|
|
435
|
+
if (value.includes("\0"))
|
|
436
|
+
return refuse("text", value, "holds a NUL byte, which Postgres text cannot");
|
|
437
|
+
return value;
|
|
438
|
+
}
|
|
439
|
+
/** A JSON column's text, as stored (the divergence the module docblock declares); refused when it is not JSON. */
|
|
440
|
+
function jsonbOf(value) {
|
|
441
|
+
if (typeof value !== "string")
|
|
442
|
+
return refuse("jsonb", value, "not a JSON text");
|
|
443
|
+
if (value.includes("\0"))
|
|
444
|
+
return refuse("jsonb", value, "holds a NUL byte, which Postgres jsonb cannot");
|
|
445
|
+
try {
|
|
446
|
+
JSON.parse(value);
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
// JSON.parse throws exactly one thing, a SyntaxError naming the position; it becomes the refusal's reason.
|
|
450
|
+
return refuse("jsonb", value, `not JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
451
|
+
}
|
|
452
|
+
return value;
|
|
453
|
+
}
|
|
454
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
455
|
+
// The encoders
|
|
456
|
+
/** The text format of a value: what `SELECT` prints, what psql shows. */
|
|
457
|
+
export function encodeText(type, value, session) {
|
|
458
|
+
switch (type) {
|
|
459
|
+
case "timestamp": return Buffer.from(timestampText(value), "ascii");
|
|
460
|
+
case "int2":
|
|
461
|
+
case "int4":
|
|
462
|
+
case "int8": return Buffer.from(integerOf(type, value).toString(), "ascii");
|
|
463
|
+
case "float4":
|
|
464
|
+
case "float8": return Buffer.from(formatFloat(floatOf(type, value), type), "ascii");
|
|
465
|
+
case "numeric": return Buffer.from(NUMERIC_SPECIAL.has(value) ? value : numericText(exactOf(value)), "ascii");
|
|
466
|
+
case "bool": return Buffer.from(boolOf(value) ? "t" : "f", "ascii");
|
|
467
|
+
case "uuid": return Buffer.from(uuidOf(value), "ascii");
|
|
468
|
+
case "date": return Buffer.from(dateOf(value).text, "ascii");
|
|
469
|
+
case "timestamptz": return Buffer.from(timestamptzText(value, session), "ascii");
|
|
470
|
+
case "text": return Buffer.from(textOf(value), "utf8");
|
|
471
|
+
case "jsonb": return Buffer.from(jsonbOf(value), "utf8");
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/** The binary format of a value: what a client asks for with a result format code of 1 (JDBC on a server-prepared
|
|
475
|
+
* statement). The session's zone does not change an instant's bytes, only its text. */
|
|
476
|
+
export function encodeBinary(type, value, _session) {
|
|
477
|
+
switch (type) {
|
|
478
|
+
case "timestamp": {
|
|
479
|
+
const { ms, micros } = wallClockOf(value);
|
|
480
|
+
const b = Buffer.alloc(8);
|
|
481
|
+
b.writeBigInt64BE(BigInt(ms - PG_EPOCH_MS) * 1000n + BigInt(micros));
|
|
482
|
+
return b;
|
|
483
|
+
}
|
|
484
|
+
case "int2": {
|
|
485
|
+
const b = Buffer.alloc(2);
|
|
486
|
+
b.writeInt16BE(Number(integerOf(type, value)));
|
|
487
|
+
return b;
|
|
488
|
+
}
|
|
489
|
+
case "int4": {
|
|
490
|
+
const b = Buffer.alloc(4);
|
|
491
|
+
b.writeInt32BE(Number(integerOf(type, value)));
|
|
492
|
+
return b;
|
|
493
|
+
}
|
|
494
|
+
case "int8": {
|
|
495
|
+
const b = Buffer.alloc(8);
|
|
496
|
+
b.writeBigInt64BE(integerOf(type, value));
|
|
497
|
+
return b;
|
|
498
|
+
}
|
|
499
|
+
case "float4": {
|
|
500
|
+
const b = Buffer.alloc(4);
|
|
501
|
+
b.writeFloatBE(floatOf(type, value));
|
|
502
|
+
return b;
|
|
503
|
+
}
|
|
504
|
+
case "float8": {
|
|
505
|
+
const b = Buffer.alloc(8);
|
|
506
|
+
b.writeDoubleBE(floatOf(type, value));
|
|
507
|
+
return b;
|
|
508
|
+
}
|
|
509
|
+
case "numeric": {
|
|
510
|
+
// NaN and ±Infinity: no digits, the sign word says which (numeric_send's NUMERIC_NAN / PINF / NINF)
|
|
511
|
+
const special = NUMERIC_SPECIAL.get(value);
|
|
512
|
+
if (special !== undefined) {
|
|
513
|
+
const b = Buffer.alloc(8);
|
|
514
|
+
b.writeUInt16BE(special, 4);
|
|
515
|
+
return b;
|
|
516
|
+
}
|
|
517
|
+
return numericBinary(exactOf(value));
|
|
518
|
+
}
|
|
519
|
+
case "bool": return Buffer.from([boolOf(value) ? 1 : 0]);
|
|
520
|
+
case "uuid": return Buffer.from(uuidOf(value).replaceAll("-", ""), "hex");
|
|
521
|
+
case "date": {
|
|
522
|
+
const b = Buffer.alloc(4);
|
|
523
|
+
b.writeInt32BE(dateOf(value).days);
|
|
524
|
+
return b;
|
|
525
|
+
}
|
|
526
|
+
case "timestamptz": {
|
|
527
|
+
const { ms, micros } = instantOf(value);
|
|
528
|
+
const b = Buffer.alloc(8);
|
|
529
|
+
b.writeBigInt64BE(BigInt(ms - PG_EPOCH_MS) * 1000n + BigInt(micros));
|
|
530
|
+
return b;
|
|
531
|
+
}
|
|
532
|
+
case "text": return Buffer.from(textOf(value), "utf8");
|
|
533
|
+
case "jsonb": return Buffer.concat([Buffer.from([1]), Buffer.from(jsonbOf(value), "utf8")]);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/** One cell for a DataRow: null for SQL NULL (the API's `null`), else the bytes in the requested format. */
|
|
537
|
+
export function encodeValue(type, value, format, session) {
|
|
538
|
+
if (value === null || value === undefined)
|
|
539
|
+
return null;
|
|
540
|
+
return format === 1 ? encodeBinary(type, value, session) : encodeText(type, value, session);
|
|
541
|
+
}
|