terrascale 0.3.0
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 +143 -0
- package/package.json +159 -0
- package/sdk-current-contract.json +27 -0
- package/sdk-route-manifest.json +67 -0
- package/src/admin.js +9 -0
- package/src/better-auth.js +14 -0
- package/src/config.js +121 -0
- package/src/database-codec.js +845 -0
- package/src/database-types.js +237 -0
- package/src/database-view.js +422 -0
- package/src/database.js +420 -0
- package/src/discovery.js +374 -0
- package/src/http.js +887 -0
- package/src/index.js +79 -0
- package/src/local/authentication.js +47 -0
- package/src/local/better-auth.js +517 -0
- package/src/local/cli.js +51 -0
- package/src/local/context.js +23 -0
- package/src/local/environment.js +109 -0
- package/src/local/index.js +204 -0
- package/src/local/router.js +999 -0
- package/src/local/server.js +664 -0
- package/src/local/store.js +530 -0
- package/src/local/test-environment.js +74 -0
- package/src/management-contracts.js +72 -0
- package/src/management.js +12 -0
- package/src/native-origin.js +75 -0
- package/src/postgres.js +494 -0
- package/src/react/core.js +743 -0
- package/src/react/index.js +99 -0
- package/src/result.js +251 -0
- package/src/schema.js +366 -0
- package/src/sql.js +996 -0
- package/src/svelte/index.js +129 -0
- package/src/tanstack/index.js +511 -0
- package/src/ts-auth-discovery.js +190 -0
- package/src/ts-auth.js +3497 -0
- package/types/admin.d.ts +6 -0
- package/types/better-auth.d.ts +8 -0
- package/types/config.d.ts +58 -0
- package/types/database-codec.d.ts +111 -0
- package/types/database-types.d.ts +213 -0
- package/types/database-view.d.ts +183 -0
- package/types/database.d.ts +98 -0
- package/types/discovery.d.ts +114 -0
- package/types/http.d.ts +46 -0
- package/types/index.d.ts +52 -0
- package/types/local/authentication.d.ts +11 -0
- package/types/local/better-auth.d.ts +33 -0
- package/types/local/cli.d.ts +2 -0
- package/types/local/context.d.ts +14 -0
- package/types/local/environment.d.ts +23 -0
- package/types/local/index.d.ts +94 -0
- package/types/local/router.d.ts +66 -0
- package/types/local/server.d.ts +54 -0
- package/types/local/store.d.ts +106 -0
- package/types/local/test-environment.d.ts +25 -0
- package/types/management-contracts.d.ts +44 -0
- package/types/management.d.ts +6 -0
- package/types/native-origin.d.ts +23 -0
- package/types/postgres.d.ts +123 -0
- package/types/react/core.d.ts +366 -0
- package/types/react/index.d.ts +54 -0
- package/types/result.d.ts +161 -0
- package/types/schema.d.ts +145 -0
- package/types/sql.d.ts +288 -0
- package/types/svelte/index.d.ts +81 -0
- package/types/tanstack/index.d.ts +165 -0
- package/types/ts-auth-discovery.d.ts +11 -0
- package/types/ts-auth.d.ts +1826 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Internal admission policy shared by native clients and qualification configuration. */
|
|
2
|
+
export const terraScaleServiceDomain = "terrascale.tech";
|
|
3
|
+
const dataApiDomain = `api.${terraScaleServiceDomain}`;
|
|
4
|
+
|
|
5
|
+
const reservedRegions = new Set([
|
|
6
|
+
"API", "ATLAS", "AUTH", "SYNC", "STATUS", "WWW", "APP", "DASH",
|
|
7
|
+
"DOCS", "GIT", "CDN", "ADMIN", "MCP", "MAIL",
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {unknown} value
|
|
12
|
+
* @returns {value is string}
|
|
13
|
+
*/
|
|
14
|
+
function isNativeRegion(value) {
|
|
15
|
+
return typeof value === "string" && /^[A-Z0-9][A-Z0-9-]{0,15}$/u.test(value) && !value.endsWith("-") && !value.includes("--") && !reservedRegions.has(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {unknown} value
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function requireNativeRegion(value) {
|
|
23
|
+
if (!isNativeRegion(value)) throw new TypeError("Invalid native region.");
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {unknown} code
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function nativeRegionalOrigin(code) {
|
|
32
|
+
return `https://${requireNativeRegion(code).toLowerCase()}.${dataApiDomain}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Hosted identity refuses database API origins, without using native admission.
|
|
37
|
+
* @param {string} hostname
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
export function isDataApiHostname(hostname) {
|
|
41
|
+
const canonical = hostname.toLowerCase().replace(/\.+$/u, "");
|
|
42
|
+
return canonical === dataApiDomain || canonical.endsWith(`.${dataApiDomain}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} raw
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function nativeServiceOrigin(raw) {
|
|
50
|
+
const url = new URL(raw);
|
|
51
|
+
if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "" || url.pathname !== "/") {
|
|
52
|
+
throw new TypeError("Service origin must be bare and contain no credentials.");
|
|
53
|
+
}
|
|
54
|
+
const authority = /** @type {string} */ (raw.slice(raw.indexOf("//") + 2).split(/[/?#]/u, 1)[0]);
|
|
55
|
+
const rawHostname = authority.startsWith("[") ? authority.slice(0, authority.indexOf("]") + 1) : /** @type {string} */ (authority.split(":", 1)[0]);
|
|
56
|
+
// Independent origins may use ASCII case aliases, but must not be silently
|
|
57
|
+
// redirected through percent decoding or IDNA hostname normalization.
|
|
58
|
+
if (!/^[\x00-\x7f]+$/u.test(rawHostname) || rawHostname.toLowerCase() !== url.hostname) {
|
|
59
|
+
throw new TypeError("Service origins must use a canonical ASCII hostname.");
|
|
60
|
+
}
|
|
61
|
+
const hostname = url.hostname.replace(/\.+$/u, "");
|
|
62
|
+
const regionalSuffix = `.${terraScaleServiceDomain}`;
|
|
63
|
+
if (hostname === terraScaleServiceDomain || hostname.endsWith(regionalSuffix)) {
|
|
64
|
+
const code = hostname.endsWith(`.${dataApiDomain}`) ? hostname.slice(0, -(dataApiDomain.length + 1)).toUpperCase() : "";
|
|
65
|
+
if (!isNativeRegion(code) || url.origin !== nativeRegionalOrigin(code) || url.hostname !== hostname || authority !== hostname) {
|
|
66
|
+
throw new TypeError("TerraScale service origins must use a canonical regional hostname.");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (url.hostname !== hostname) throw new TypeError("Service origins must use a canonical hostname.");
|
|
70
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(hostname) || hostname.endsWith(".localhost");
|
|
71
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
72
|
+
throw new TypeError("Service origins must use HTTPS, except loopback development.");
|
|
73
|
+
}
|
|
74
|
+
return url.origin;
|
|
75
|
+
}
|
package/src/postgres.js
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/** Node-only PostgreSQL client for the signed INT8/TEXT point SQL listener. */
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
import { isIP } from "node:net";
|
|
4
|
+
import { X509Certificate } from "node:crypto";
|
|
5
|
+
import { checkServerIdentity, createSecureContext, TLSSocket } from "node:tls";
|
|
6
|
+
/**
|
|
7
|
+
* @import {
|
|
8
|
+
* Client as DriverClient,
|
|
9
|
+
* ClientConfig,
|
|
10
|
+
* DatabaseError,
|
|
11
|
+
* FieldDef,
|
|
12
|
+
* QueryArrayConfig,
|
|
13
|
+
* QueryArrayResult,
|
|
14
|
+
* } from "pg"
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const MAX_BYTES = 16 * 1024;
|
|
18
|
+
const MIN_INT8 = -(1n << 63n);
|
|
19
|
+
const MAX_INT8 = (1n << 63n) - 1n;
|
|
20
|
+
|
|
21
|
+
/** @typedef {bigint | string} PostgresParameter */
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {{
|
|
24
|
+
* readonly statement: string;
|
|
25
|
+
* readonly parameters: readonly unknown[];
|
|
26
|
+
* }} PostgresQuery
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {{
|
|
30
|
+
* readonly statement: string;
|
|
31
|
+
* readonly parameters: readonly PostgresParameter[];
|
|
32
|
+
* }} PostgresBoundQuery
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {{
|
|
36
|
+
* readonly host: string;
|
|
37
|
+
* readonly port: number;
|
|
38
|
+
* readonly tlsServerName: string;
|
|
39
|
+
* readonly caCertificatePem: string;
|
|
40
|
+
* readonly database: string;
|
|
41
|
+
* readonly keyId: string;
|
|
42
|
+
* readonly password: string;
|
|
43
|
+
* readonly connectTimeoutMs?: number;
|
|
44
|
+
* }} PostgresConfig
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {{
|
|
48
|
+
* readonly name: string;
|
|
49
|
+
* readonly oid: 20 | 25;
|
|
50
|
+
* }} PostgresColumn
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {{
|
|
54
|
+
* readonly command: string;
|
|
55
|
+
* readonly affectedRows: number;
|
|
56
|
+
* readonly columns: readonly PostgresColumn[];
|
|
57
|
+
* readonly rows: readonly (readonly PostgresParameter[])[];
|
|
58
|
+
* }} PostgresResult
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** Diagnostics retain SQLSTATE, but never driver messages, SQL or passwords. */
|
|
62
|
+
export class PostgresError extends Error {
|
|
63
|
+
/**
|
|
64
|
+
* @override
|
|
65
|
+
* @readonly
|
|
66
|
+
*/
|
|
67
|
+
name = /** @type {const} */ ("PostgresError");
|
|
68
|
+
/**
|
|
69
|
+
* @readonly
|
|
70
|
+
* @type {"configuration" | "query" | "connection" | "database" | "protocol"}
|
|
71
|
+
*/
|
|
72
|
+
kind;
|
|
73
|
+
/**
|
|
74
|
+
* @readonly
|
|
75
|
+
* @type {string | undefined}
|
|
76
|
+
*/
|
|
77
|
+
sqlState;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {"configuration" | "query" | "connection" | "database" | "protocol"} kind
|
|
81
|
+
* @param {string} [sqlState]
|
|
82
|
+
*/
|
|
83
|
+
constructor(kind, sqlState) {
|
|
84
|
+
super(
|
|
85
|
+
`terrascale PostgreSQL ${kind} failure${sqlState === undefined ? "" : ` (${sqlState})`}`,
|
|
86
|
+
);
|
|
87
|
+
this.kind = kind;
|
|
88
|
+
this.sqlState = sqlState;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} value
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
function validText(value) {
|
|
97
|
+
if (value.includes("\0") || Buffer.byteLength(value, "utf8") > MAX_BYTES)
|
|
98
|
+
return false;
|
|
99
|
+
for (let index = 0; index < value.length; index++) {
|
|
100
|
+
const code = value.charCodeAt(index);
|
|
101
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
102
|
+
const next = value.charCodeAt(++index);
|
|
103
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
|
|
104
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) return false;
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Validate and clone a builder descriptor without converting or interpolating values.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} statement
|
|
113
|
+
* @param {readonly unknown[]} [parameters]
|
|
114
|
+
* @returns {PostgresBoundQuery}
|
|
115
|
+
*/
|
|
116
|
+
export function postgresQuery(statement, parameters = []) {
|
|
117
|
+
if (
|
|
118
|
+
typeof statement !== "string" ||
|
|
119
|
+
statement.trim() === "" ||
|
|
120
|
+
!validText(statement) ||
|
|
121
|
+
!Array.isArray(parameters) ||
|
|
122
|
+
parameters.length > 4
|
|
123
|
+
)
|
|
124
|
+
throw new PostgresError("query");
|
|
125
|
+
/** @type {PostgresParameter[]} */
|
|
126
|
+
const values = parameters.map((value) => {
|
|
127
|
+
if (typeof value === "bigint" && value >= MIN_INT8 && value <= MAX_INT8)
|
|
128
|
+
return value;
|
|
129
|
+
if (typeof value === "string" && validText(value)) return value;
|
|
130
|
+
throw new PostgresError("query");
|
|
131
|
+
});
|
|
132
|
+
return Object.freeze({ statement, parameters: Object.freeze(values) });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {string} value
|
|
137
|
+
* @param {number} bytes
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
function canonicalBase64(value, bytes) {
|
|
141
|
+
return (
|
|
142
|
+
/^[A-Za-z0-9_-]+$/.test(value) &&
|
|
143
|
+
Buffer.from(value, "base64url").length === bytes &&
|
|
144
|
+
Buffer.from(value, "base64url").toString("base64url") === value
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {PostgresConfig} config
|
|
150
|
+
* @returns {PostgresConfig & { readonly connectTimeoutMs: number }}
|
|
151
|
+
*/
|
|
152
|
+
function configuration(config) {
|
|
153
|
+
const timeout = config.connectTimeoutMs ?? 5000;
|
|
154
|
+
const parts =
|
|
155
|
+
typeof config.password === "string" ? config.password.split(".") : [];
|
|
156
|
+
if (
|
|
157
|
+
typeof config.host !== "string" ||
|
|
158
|
+
config.host === "" ||
|
|
159
|
+
config.host.length > 253 ||
|
|
160
|
+
/[\s,\/\0]/.test(config.host) ||
|
|
161
|
+
!Number.isInteger(config.port) ||
|
|
162
|
+
config.port < 1 ||
|
|
163
|
+
config.port > 65535 ||
|
|
164
|
+
typeof config.tlsServerName !== "string" ||
|
|
165
|
+
config.tlsServerName === "" ||
|
|
166
|
+
/[\s,\/\0]/.test(config.tlsServerName) ||
|
|
167
|
+
!Number.isInteger(timeout) ||
|
|
168
|
+
timeout < 1 ||
|
|
169
|
+
timeout > 10000 ||
|
|
170
|
+
!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(config.database) ||
|
|
171
|
+
!canonicalBase64(config.keyId, 16) ||
|
|
172
|
+
parts.length !== 3 ||
|
|
173
|
+
parts[0] !== "tbk1" ||
|
|
174
|
+
parts[1] !== config.keyId ||
|
|
175
|
+
!canonicalBase64(parts[2] ?? "", 32) ||
|
|
176
|
+
typeof config.caCertificatePem !== "string" ||
|
|
177
|
+
config.caCertificatePem.length > 64 * 1024
|
|
178
|
+
) {
|
|
179
|
+
throw new PostgresError("configuration");
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const certificates = config.caCertificatePem.match(
|
|
183
|
+
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g,
|
|
184
|
+
);
|
|
185
|
+
if (certificates === null || certificates.length === 0)
|
|
186
|
+
throw new PostgresError("configuration");
|
|
187
|
+
for (const certificate of certificates) new X509Certificate(certificate);
|
|
188
|
+
createSecureContext({
|
|
189
|
+
ca: config.caCertificatePem,
|
|
190
|
+
minVersion: "TLSv1.3",
|
|
191
|
+
maxVersion: "TLSv1.3",
|
|
192
|
+
});
|
|
193
|
+
} catch {
|
|
194
|
+
throw new PostgresError("configuration");
|
|
195
|
+
}
|
|
196
|
+
return Object.freeze({ ...config, connectTimeoutMs: timeout });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @param {unknown} error
|
|
201
|
+
* @param {typeof DatabaseError} [databaseError]
|
|
202
|
+
* @returns {PostgresError}
|
|
203
|
+
*/
|
|
204
|
+
function failure(error, databaseError) {
|
|
205
|
+
if (error instanceof PostgresError) return error;
|
|
206
|
+
if (
|
|
207
|
+
databaseError !== undefined &&
|
|
208
|
+
error instanceof databaseError &&
|
|
209
|
+
typeof error.code === "string" &&
|
|
210
|
+
/^[0-9A-Z]{5}$/.test(error.code)
|
|
211
|
+
) {
|
|
212
|
+
return new PostgresError("database", error.code);
|
|
213
|
+
}
|
|
214
|
+
return new PostgresError("connection");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param {readonly FieldDef[]} fields
|
|
219
|
+
* @returns {readonly PostgresColumn[]}
|
|
220
|
+
*/
|
|
221
|
+
function columns(fields) {
|
|
222
|
+
return Object.freeze(
|
|
223
|
+
fields.map((field) => {
|
|
224
|
+
if (field.dataTypeID !== 20 && field.dataTypeID !== 25)
|
|
225
|
+
throw new PostgresError("protocol");
|
|
226
|
+
return Object.freeze({ name: field.name, oid: field.dataTypeID });
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** One driver connection; no automatic reconnect, mutation retry or callback replay. */
|
|
232
|
+
export class PostgresClient {
|
|
233
|
+
/** @type {DriverClient} */
|
|
234
|
+
#driver;
|
|
235
|
+
/** @type {typeof DatabaseError} */
|
|
236
|
+
#databaseError;
|
|
237
|
+
#closed = false;
|
|
238
|
+
/**
|
|
239
|
+
* @private
|
|
240
|
+
* @param {DriverClient} driver
|
|
241
|
+
* @param {typeof DatabaseError} databaseError
|
|
242
|
+
*/
|
|
243
|
+
constructor(driver, databaseError) {
|
|
244
|
+
this.#driver = driver;
|
|
245
|
+
this.#databaseError = databaseError;
|
|
246
|
+
driver.on("error", () => {
|
|
247
|
+
this.#closed = true;
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* @param {PostgresConfig} input
|
|
253
|
+
* @returns {Promise<PostgresClient>}
|
|
254
|
+
*/
|
|
255
|
+
static async connect(input) {
|
|
256
|
+
const config = configuration(input);
|
|
257
|
+
// pg treats empty options/replication as absent and reads these variables.
|
|
258
|
+
// Reject them before loading the driver or opening a socket.
|
|
259
|
+
if (
|
|
260
|
+
process.env["NODE_PG_FORCE_NATIVE"] ||
|
|
261
|
+
process.env["PGOPTIONS"] ||
|
|
262
|
+
process.env["PGREPLICATION"]
|
|
263
|
+
) {
|
|
264
|
+
throw new PostgresError("configuration");
|
|
265
|
+
}
|
|
266
|
+
const started = Date.now();
|
|
267
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
268
|
+
let timer;
|
|
269
|
+
try {
|
|
270
|
+
// pg overwrites SNI for DNS hosts. Resolve only the configured address,
|
|
271
|
+
// then send the separately pinned TLS name through its SSL options.
|
|
272
|
+
const address = isIP(config.host)
|
|
273
|
+
? config.host
|
|
274
|
+
: await Promise.race([
|
|
275
|
+
lookup(config.host).then((result) => result.address),
|
|
276
|
+
/** @type {Promise<never>} */ (
|
|
277
|
+
new Promise((_resolve, reject) => {
|
|
278
|
+
timer = setTimeout(() => {
|
|
279
|
+
reject(new PostgresError("connection"));
|
|
280
|
+
}, config.connectTimeoutMs);
|
|
281
|
+
})
|
|
282
|
+
),
|
|
283
|
+
]);
|
|
284
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
285
|
+
const remaining = config.connectTimeoutMs - (Date.now() - started);
|
|
286
|
+
if (remaining <= 0) throw new PostgresError("connection");
|
|
287
|
+
const { default: pg } = await import("pg");
|
|
288
|
+
// Pinned pg 8.23 emits sslconnect immediately after creating TLSSocket.
|
|
289
|
+
// Its Client sends startup on that event, so fence the event itself:
|
|
290
|
+
// waiting for Client.connect() would disclose credentials too early.
|
|
291
|
+
class VerifiedTlsConnection extends pg.Connection {
|
|
292
|
+
#tlsStarted = false;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @override
|
|
296
|
+
* @param {string | symbol} event
|
|
297
|
+
* @param {...unknown} args
|
|
298
|
+
* @returns {boolean}
|
|
299
|
+
*/
|
|
300
|
+
emit(event, ...args) {
|
|
301
|
+
if (event !== "sslconnect") return super.emit(event, ...args);
|
|
302
|
+
const hasListeners = this.listenerCount(event) > 0;
|
|
303
|
+
if (this.#tlsStarted) return hasListeners;
|
|
304
|
+
this.#tlsStarted = true;
|
|
305
|
+
const socket = this.stream;
|
|
306
|
+
if (!(socket instanceof TLSSocket)) {
|
|
307
|
+
socket.destroy(new PostgresError("protocol"));
|
|
308
|
+
return hasListeners;
|
|
309
|
+
}
|
|
310
|
+
/** @returns {void} */
|
|
311
|
+
const cleanup = () => {
|
|
312
|
+
socket.off("secureConnect", verified);
|
|
313
|
+
socket.off("error", cleanup);
|
|
314
|
+
socket.off("close", cleanup);
|
|
315
|
+
};
|
|
316
|
+
/** @returns {void} */
|
|
317
|
+
const verified = () => {
|
|
318
|
+
cleanup();
|
|
319
|
+
if (socket.destroyed) return;
|
|
320
|
+
if (!socket.authorized || socket.alpnProtocol !== "postgresql") {
|
|
321
|
+
socket.destroy(new PostgresError("protocol"));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
super.emit(event, ...args);
|
|
325
|
+
};
|
|
326
|
+
socket.once("secureConnect", verified);
|
|
327
|
+
socket.once("error", cleanup);
|
|
328
|
+
socket.once("close", cleanup);
|
|
329
|
+
return hasListeners;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* @type {ClientConfig & {
|
|
334
|
+
* binary: false;
|
|
335
|
+
* client_encoding: string;
|
|
336
|
+
* sslnegotiation: "postgres";
|
|
337
|
+
* }}
|
|
338
|
+
*/
|
|
339
|
+
const driverConfig = {
|
|
340
|
+
host: address,
|
|
341
|
+
port: config.port,
|
|
342
|
+
database: config.database,
|
|
343
|
+
user: config.keyId,
|
|
344
|
+
password: config.password,
|
|
345
|
+
connectionTimeoutMillis: remaining,
|
|
346
|
+
application_name: "terrascale-postgres",
|
|
347
|
+
options: "",
|
|
348
|
+
binary: false,
|
|
349
|
+
client_encoding: "UTF8",
|
|
350
|
+
sslnegotiation: "postgres",
|
|
351
|
+
ssl: {
|
|
352
|
+
ca: config.caCertificatePem,
|
|
353
|
+
minVersion: "TLSv1.3",
|
|
354
|
+
maxVersion: "TLSv1.3",
|
|
355
|
+
rejectUnauthorized: true,
|
|
356
|
+
ALPNProtocols: ["postgresql"],
|
|
357
|
+
servername: config.tlsServerName,
|
|
358
|
+
checkServerIdentity: (_host, certificate) =>
|
|
359
|
+
checkServerIdentity(config.tlsServerName, certificate),
|
|
360
|
+
},
|
|
361
|
+
types: {
|
|
362
|
+
getTypeParser: (
|
|
363
|
+
/** @type {number} */ oid,
|
|
364
|
+
/** @type {string | undefined} */ format,
|
|
365
|
+
) => {
|
|
366
|
+
if (format === "binary")
|
|
367
|
+
return () => {
|
|
368
|
+
throw new PostgresError("protocol");
|
|
369
|
+
};
|
|
370
|
+
if (oid === 20)
|
|
371
|
+
return (/** @type {string} */ value) => BigInt(value);
|
|
372
|
+
return (/** @type {string} */ value) => value;
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
// Connection's pinned runtime option uses camelCase; the driver's public
|
|
377
|
+
// ClientConfig type does not yet declare the supported connection seam.
|
|
378
|
+
/** @type {ClientConfig & { sslNegotiation: "postgres" }} */
|
|
379
|
+
const connectionConfig = {
|
|
380
|
+
ssl: driverConfig.ssl,
|
|
381
|
+
sslNegotiation: "postgres",
|
|
382
|
+
};
|
|
383
|
+
/**
|
|
384
|
+
* @type {ClientConfig & {
|
|
385
|
+
* connection: InstanceType<typeof VerifiedTlsConnection>;
|
|
386
|
+
* }}
|
|
387
|
+
*/
|
|
388
|
+
const clientConfig = {
|
|
389
|
+
...driverConfig,
|
|
390
|
+
connection: new VerifiedTlsConnection(connectionConfig),
|
|
391
|
+
};
|
|
392
|
+
const driver = new pg.Client(clientConfig);
|
|
393
|
+
const client = new PostgresClient(driver, pg.DatabaseError);
|
|
394
|
+
try {
|
|
395
|
+
await driver.connect();
|
|
396
|
+
return client;
|
|
397
|
+
} catch (error) {
|
|
398
|
+
await driver.end().catch(() => undefined);
|
|
399
|
+
throw failure(error, pg.DatabaseError);
|
|
400
|
+
}
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw failure(error);
|
|
403
|
+
} finally {
|
|
404
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* @param {PostgresQuery} input
|
|
410
|
+
* @returns {Promise<PostgresResult>}
|
|
411
|
+
*/
|
|
412
|
+
async query(input) {
|
|
413
|
+
const query = postgresQuery(input.statement, input.parameters);
|
|
414
|
+
if (this.#closed) throw new PostgresError("connection");
|
|
415
|
+
try {
|
|
416
|
+
/** @type {QueryArrayConfig<string[]> & { binary: false }} */
|
|
417
|
+
const queryConfig = {
|
|
418
|
+
text: query.statement,
|
|
419
|
+
values: query.parameters.map((value) =>
|
|
420
|
+
typeof value === "bigint" ? value.toString() : value,
|
|
421
|
+
),
|
|
422
|
+
rowMode: "array",
|
|
423
|
+
binary: false,
|
|
424
|
+
};
|
|
425
|
+
const result = /** @type {QueryArrayResult<unknown[]>} */ (
|
|
426
|
+
await this.#driver.query(queryConfig)
|
|
427
|
+
);
|
|
428
|
+
const schema = columns(result.fields);
|
|
429
|
+
if (
|
|
430
|
+
result.rows.length > 1 ||
|
|
431
|
+
![
|
|
432
|
+
"SELECT",
|
|
433
|
+
"INSERT",
|
|
434
|
+
"UPDATE",
|
|
435
|
+
"DELETE",
|
|
436
|
+
"BEGIN",
|
|
437
|
+
"COMMIT",
|
|
438
|
+
"ROLLBACK",
|
|
439
|
+
"SET",
|
|
440
|
+
].includes(result.command)
|
|
441
|
+
) {
|
|
442
|
+
throw new PostgresError("protocol");
|
|
443
|
+
}
|
|
444
|
+
const rows = result.rows.map((row) => {
|
|
445
|
+
if (row.length !== schema.length) throw new PostgresError("protocol");
|
|
446
|
+
return Object.freeze(
|
|
447
|
+
row.map((value, index) => {
|
|
448
|
+
if (
|
|
449
|
+
schema[index]?.oid === 20 &&
|
|
450
|
+
typeof value === "bigint" &&
|
|
451
|
+
value >= MIN_INT8 &&
|
|
452
|
+
value <= MAX_INT8
|
|
453
|
+
)
|
|
454
|
+
return value;
|
|
455
|
+
if (
|
|
456
|
+
schema[index]?.oid === 25 &&
|
|
457
|
+
typeof value === "string" &&
|
|
458
|
+
validText(value)
|
|
459
|
+
)
|
|
460
|
+
return value;
|
|
461
|
+
throw new PostgresError("protocol");
|
|
462
|
+
}),
|
|
463
|
+
);
|
|
464
|
+
});
|
|
465
|
+
const affectedRows = result.rowCount ?? 0;
|
|
466
|
+
if (
|
|
467
|
+
!Number.isSafeInteger(affectedRows) ||
|
|
468
|
+
affectedRows < 0 ||
|
|
469
|
+
affectedRows > 2
|
|
470
|
+
)
|
|
471
|
+
throw new PostgresError("protocol");
|
|
472
|
+
return Object.freeze({
|
|
473
|
+
command: result.command,
|
|
474
|
+
affectedRows,
|
|
475
|
+
columns: schema,
|
|
476
|
+
rows: Object.freeze(rows),
|
|
477
|
+
});
|
|
478
|
+
} catch (error) {
|
|
479
|
+
throw failure(error, this.#databaseError);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* @returns {Promise<void>}
|
|
485
|
+
*/
|
|
486
|
+
async close() {
|
|
487
|
+
this.#closed = true;
|
|
488
|
+
try {
|
|
489
|
+
await this.#driver.end();
|
|
490
|
+
} catch (error) {
|
|
491
|
+
throw failure(error, this.#databaseError);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|