@dalgo/dalgo2postgrest 0.0.0-stage → 0.2.1
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 +21 -0
- package/README.md +404 -2
- package/dist/capabilities.d.ts +129 -0
- package/dist/capabilities.js +78 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/cursor.d.ts +60 -0
- package/dist/cursor.js +261 -0
- package/dist/cursor.js.map +1 -0
- package/dist/database.d.ts +95 -0
- package/dist/database.js +730 -0
- package/dist/database.js.map +1 -0
- package/dist/discovery.d.ts +111 -0
- package/dist/discovery.js +235 -0
- package/dist/discovery.js.map +1 -0
- package/dist/fields.d.ts +20 -0
- package/dist/fields.js +32 -0
- package/dist/fields.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +25 -0
- package/dist/json.js +114 -0
- package/dist/json.js.map +1 -0
- package/package.json +60 -4
package/dist/database.js
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import { QueryCancelledError, cancelQuietly, createAbortScope, raceAbort, } from "@dalgo/adapter-runtime";
|
|
2
|
+
import { AlreadyExistsError, DOCUMENT_ID, Key, NotFoundError, UnsupportedError, identityCodec, } from "@dalgo/core";
|
|
3
|
+
import { PostgrestCursorError, decodePostgrestCursor, encodePostgrestCursor, postgrestKeysetFilter, } from "./cursor.js";
|
|
4
|
+
import { parsePostgrestOpenApi, } from "./discovery.js";
|
|
5
|
+
import { postgrestFieldKey } from "./fields.js";
|
|
6
|
+
import { isUnsafeNativeNumber, parseLosslessJson } from "./json.js";
|
|
7
|
+
const DEFAULT_MAX_GET_MANY = 100;
|
|
8
|
+
const DEFAULT_MAX_PARALLEL_READS = 8;
|
|
9
|
+
const DEFAULT_MAX_QUERY_LIMIT = 100;
|
|
10
|
+
const DEFAULT_MAX_QUERY_OFFSET = 100_000;
|
|
11
|
+
const DEFAULT_MAX_QUERY_PAGES = 10_000;
|
|
12
|
+
const DEFAULT_MAX_REQUEST_BYTES = 1_048_576;
|
|
13
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 1_048_576;
|
|
14
|
+
const DEFAULT_MAX_DISCOVERY_RELATIONS = 100;
|
|
15
|
+
const DEFAULT_MAX_DISCOVERY_FIELDS = 200;
|
|
16
|
+
const DEFAULT_MAX_DISCOVERY_BYTES = 524_288;
|
|
17
|
+
const DEFAULT_MAX_CURSOR_BYTES = 8_192;
|
|
18
|
+
const DEFAULT_MAX_SCHEMA_DEPTH = 4;
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
20
|
+
/** Deliberately excludes PostgREST/PostgreSQL error bodies, which can contain row values. */
|
|
21
|
+
export class PostgrestHttpError extends Error {
|
|
22
|
+
status;
|
|
23
|
+
constructor(status) {
|
|
24
|
+
super(`PostgREST request failed with HTTP ${String(status)}`);
|
|
25
|
+
this.name = "PostgrestHttpError";
|
|
26
|
+
this.status = status;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** A redacted failure while obtaining headers, following a request, or consuming a response. */
|
|
30
|
+
export class PostgrestRequestError extends Error {
|
|
31
|
+
constructor() {
|
|
32
|
+
super("PostgREST request could not be completed");
|
|
33
|
+
this.name = "PostgrestRequestError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function positive(value, fallback, field, maximum = 16_777_216) {
|
|
37
|
+
const result = value ?? fallback;
|
|
38
|
+
if (!Number.isSafeInteger(result) || result < 1 || result > maximum) {
|
|
39
|
+
throw new TypeError(`${field} must be a safe integer from 1 to ${String(maximum)}`);
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
function codecOrIdentity(codec) {
|
|
44
|
+
return (codec ?? identityCodec);
|
|
45
|
+
}
|
|
46
|
+
function isPlainObject(value) {
|
|
47
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
48
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
49
|
+
}
|
|
50
|
+
function validateJson(value, seen = new Set()) {
|
|
51
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
52
|
+
return;
|
|
53
|
+
if (typeof value === "number") {
|
|
54
|
+
if (!Number.isFinite(value) || Object.is(value, -0) || isUnsafeNativeNumber(value)) {
|
|
55
|
+
throw new TypeError("PostgREST JSON numbers must be finite, lossless and not -0");
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
if (seen.has(value))
|
|
61
|
+
throw new TypeError("PostgREST JSON must not contain cycles");
|
|
62
|
+
seen.add(value);
|
|
63
|
+
for (const item of value)
|
|
64
|
+
validateJson(item, seen);
|
|
65
|
+
seen.delete(value);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (!isPlainObject(value))
|
|
69
|
+
throw new TypeError("PostgREST JSON values must be plain objects, arrays, or primitives");
|
|
70
|
+
if (seen.has(value))
|
|
71
|
+
throw new TypeError("PostgREST JSON must not contain cycles");
|
|
72
|
+
seen.add(value);
|
|
73
|
+
for (const item of Object.values(value))
|
|
74
|
+
validateJson(item, seen);
|
|
75
|
+
seen.delete(value);
|
|
76
|
+
}
|
|
77
|
+
function encodedRecord(value, codec, idColumn) {
|
|
78
|
+
const encoded = codec.encode(value);
|
|
79
|
+
if (!isPlainObject(encoded))
|
|
80
|
+
throw new TypeError("PostgREST DALgo records must encode to a non-null plain JSON object");
|
|
81
|
+
if (Object.hasOwn(encoded, idColumn))
|
|
82
|
+
throw new TypeError(`PostgREST record data must not contain the adapter-owned ${idColumn} column`);
|
|
83
|
+
validateJson(encoded);
|
|
84
|
+
return encoded;
|
|
85
|
+
}
|
|
86
|
+
function validateHeaders(headers) {
|
|
87
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
88
|
+
if (typeof value !== "string" || /[\r\n]/u.test(name) || /[\r\n]/u.test(value)) {
|
|
89
|
+
throw new TypeError("PostgREST configured headers must be CR/LF-safe strings");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function identifier(value, label) {
|
|
94
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(value))
|
|
95
|
+
throw new UnsupportedError(`PostgREST ${label} names`);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
function profileName(value) {
|
|
99
|
+
if (value === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
return identifier(value, "schema/profile");
|
|
102
|
+
}
|
|
103
|
+
function identityScopeName(value) {
|
|
104
|
+
if (value === undefined)
|
|
105
|
+
return undefined;
|
|
106
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 256) {
|
|
107
|
+
throw new TypeError("identityScope must be a non-empty string of at most 256 printable characters");
|
|
108
|
+
}
|
|
109
|
+
for (const character of value) {
|
|
110
|
+
const code = character.codePointAt(0) ?? 0;
|
|
111
|
+
if (code < 0x20 || code === 0x7f) {
|
|
112
|
+
throw new TypeError("identityScope must be a non-empty string of at most 256 printable characters");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
function scalar(value) {
|
|
118
|
+
if (typeof value === "string") {
|
|
119
|
+
// A top-level single-value filter is not a PostgREST list context: the
|
|
120
|
+
// protocol compares the operand bytes as written, so double quotes would
|
|
121
|
+
// become part of the compared value (`eq."Alpha"` does not match `Alpha`).
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
if (typeof value === "number") {
|
|
125
|
+
if (!Number.isFinite(value) || Object.is(value, -0) || isUnsafeNativeNumber(value)) {
|
|
126
|
+
throw new TypeError("PostgREST filter numbers must be finite, lossless and not -0");
|
|
127
|
+
}
|
|
128
|
+
return String(value);
|
|
129
|
+
}
|
|
130
|
+
if (typeof value === "boolean")
|
|
131
|
+
return String(value);
|
|
132
|
+
throw new UnsupportedError("PostgREST filter value types");
|
|
133
|
+
}
|
|
134
|
+
function quotedScalar(value) {
|
|
135
|
+
if (typeof value !== "string")
|
|
136
|
+
return scalar(value);
|
|
137
|
+
return `"${value.replaceAll(/(["\\])/gu, "\\$1")}"`;
|
|
138
|
+
}
|
|
139
|
+
function filterValue(value) {
|
|
140
|
+
// An array literal is a list context, so its members are quoted.
|
|
141
|
+
if (Array.isArray(value))
|
|
142
|
+
return `{${value.map(quotedScalar).join(",")}}`;
|
|
143
|
+
return scalar(value);
|
|
144
|
+
}
|
|
145
|
+
function filterParameter(filter, idColumn) {
|
|
146
|
+
const field = filter.field === DOCUMENT_ID ? idColumn : identifier(String(filter.field), "query field");
|
|
147
|
+
if (filter.value === undefined)
|
|
148
|
+
throw new UnsupportedError("PostgREST undefined query filters");
|
|
149
|
+
if (filter.value === null) {
|
|
150
|
+
if (filter.operator === "==")
|
|
151
|
+
return [postgrestFieldKey(field), "is.null"];
|
|
152
|
+
if (filter.operator === "!=")
|
|
153
|
+
return [postgrestFieldKey(field), "not.is.null"];
|
|
154
|
+
throw new UnsupportedError(`PostgREST ${filter.operator} null query filters`);
|
|
155
|
+
}
|
|
156
|
+
switch (filter.operator) {
|
|
157
|
+
case "==": return [postgrestFieldKey(field), `eq.${filterValue(filter.value)}`];
|
|
158
|
+
case "!=": return [postgrestFieldKey(field), `neq.${filterValue(filter.value)}`];
|
|
159
|
+
case "<": return [postgrestFieldKey(field), `lt.${scalar(filter.value)}`];
|
|
160
|
+
case "<=": return [postgrestFieldKey(field), `lte.${scalar(filter.value)}`];
|
|
161
|
+
case ">": return [postgrestFieldKey(field), `gt.${scalar(filter.value)}`];
|
|
162
|
+
case ">=": return [postgrestFieldKey(field), `gte.${scalar(filter.value)}`];
|
|
163
|
+
case "in":
|
|
164
|
+
if (!Array.isArray(filter.value) || filter.value.length === 0)
|
|
165
|
+
throw new TypeError("PostgREST in filters require a non-empty array");
|
|
166
|
+
return [postgrestFieldKey(field), `in.(${filter.value.map(quotedScalar).join(",")})`];
|
|
167
|
+
case "not-in":
|
|
168
|
+
if (!Array.isArray(filter.value) || filter.value.length === 0)
|
|
169
|
+
throw new TypeError("PostgREST not-in filters require a non-empty array");
|
|
170
|
+
return [postgrestFieldKey(field), `not.in.(${filter.value.map(quotedScalar).join(",")})`];
|
|
171
|
+
case "array-contains":
|
|
172
|
+
if (!Array.isArray(filter.value))
|
|
173
|
+
throw new TypeError("PostgREST array-contains filters require an array");
|
|
174
|
+
return [postgrestFieldKey(field), `cs.${filterValue(filter.value)}`];
|
|
175
|
+
case "array-contains-any":
|
|
176
|
+
if (!Array.isArray(filter.value) || filter.value.length === 0)
|
|
177
|
+
throw new TypeError("PostgREST array-contains-any filters require a non-empty array");
|
|
178
|
+
return [postgrestFieldKey(field), `ov.${filterValue(filter.value)}`];
|
|
179
|
+
default: throw new UnsupportedError(`PostgREST ${String(filter.operator)} query filters`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function readJson(response, maximum, signal, cancelled) {
|
|
183
|
+
const contentLength = response.headers.get("content-length");
|
|
184
|
+
if (contentLength !== null && (!/^\d+$/u.test(contentLength) || Number(contentLength) > maximum)) {
|
|
185
|
+
cancelQuietly(response.body);
|
|
186
|
+
throw new RangeError("PostgREST response exceeds maxResponseBytes");
|
|
187
|
+
}
|
|
188
|
+
if (response.body === null)
|
|
189
|
+
return undefined;
|
|
190
|
+
const reader = response.body.getReader();
|
|
191
|
+
const chunks = [];
|
|
192
|
+
let total = 0;
|
|
193
|
+
try {
|
|
194
|
+
for (;;) {
|
|
195
|
+
const item = await raceAbort(reader.read(), signal, cancelled);
|
|
196
|
+
if (item.done)
|
|
197
|
+
break;
|
|
198
|
+
total += item.value.byteLength;
|
|
199
|
+
if (total > maximum) {
|
|
200
|
+
cancelQuietly(reader);
|
|
201
|
+
throw new RangeError("PostgREST response exceeds maxResponseBytes");
|
|
202
|
+
}
|
|
203
|
+
chunks.push(item.value);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
if (signal.aborted)
|
|
208
|
+
cancelQuietly(reader);
|
|
209
|
+
try {
|
|
210
|
+
reader.releaseLock();
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// An implementation may retain an in-flight read after cancellation.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const bytes = new Uint8Array(total);
|
|
217
|
+
let offset = 0;
|
|
218
|
+
for (const chunk of chunks) {
|
|
219
|
+
bytes.set(chunk, offset);
|
|
220
|
+
offset += chunk.byteLength;
|
|
221
|
+
}
|
|
222
|
+
if (bytes.byteLength === 0)
|
|
223
|
+
return undefined;
|
|
224
|
+
return parseLosslessJson(new TextDecoder().decode(bytes));
|
|
225
|
+
}
|
|
226
|
+
export class PostgrestDatabase {
|
|
227
|
+
#baseUrl;
|
|
228
|
+
#schema;
|
|
229
|
+
#identityScope;
|
|
230
|
+
#idColumn;
|
|
231
|
+
#relationName;
|
|
232
|
+
#headers;
|
|
233
|
+
#fetch;
|
|
234
|
+
#timeoutMs;
|
|
235
|
+
#maxRequestBytes;
|
|
236
|
+
#maxResponseBytes;
|
|
237
|
+
#maxGetManyKeys;
|
|
238
|
+
#maxParallelReads;
|
|
239
|
+
#maxQueryLimit;
|
|
240
|
+
#maxQueryOffset;
|
|
241
|
+
#maxQueryPages;
|
|
242
|
+
#maxDiscoveryRelations;
|
|
243
|
+
#maxDiscoveryFields;
|
|
244
|
+
#maxDiscoveryBytes;
|
|
245
|
+
#maxCursorBytes;
|
|
246
|
+
#maxSchemaDepth;
|
|
247
|
+
constructor(options) {
|
|
248
|
+
if (options.schema !== undefined && options.profile !== undefined && options.schema !== options.profile) {
|
|
249
|
+
throw new TypeError("schema and profile must name the same PostgREST profile");
|
|
250
|
+
}
|
|
251
|
+
const url = new URL(options.baseUrl.trim());
|
|
252
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
253
|
+
if (url.protocol === "http:") {
|
|
254
|
+
if (!loopback || options.allowInsecureLoopbackHttp !== true) {
|
|
255
|
+
throw new TypeError("baseUrl must use HTTPS; loopback HTTP requires allowInsecureLoopbackHttp: true");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else if (url.protocol !== "https:") {
|
|
259
|
+
throw new TypeError("baseUrl must use HTTPS");
|
|
260
|
+
}
|
|
261
|
+
if (url.username.length > 0 || url.password.length > 0 || url.search.length > 0 || url.hash.length > 0) {
|
|
262
|
+
throw new TypeError("baseUrl must not contain credentials, a query, or a fragment");
|
|
263
|
+
}
|
|
264
|
+
this.#baseUrl = `${url.toString().replace(/\/+$/u, "")}/`;
|
|
265
|
+
this.#schema = profileName(options.profile ?? options.schema);
|
|
266
|
+
this.#identityScope = identityScopeName(options.identityScope);
|
|
267
|
+
this.#idColumn = identifier(options.idColumn ?? "id", "id column");
|
|
268
|
+
this.#relationName = options.relationName ?? ((collection) => collection);
|
|
269
|
+
this.#headers = options.headers;
|
|
270
|
+
this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
271
|
+
this.#timeoutMs = positive(options.timeoutMs, DEFAULT_TIMEOUT_MS, "timeoutMs", 120_000);
|
|
272
|
+
this.#maxRequestBytes = positive(options.maxRequestBytes, DEFAULT_MAX_REQUEST_BYTES, "maxRequestBytes");
|
|
273
|
+
this.#maxResponseBytes = positive(options.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES, "maxResponseBytes");
|
|
274
|
+
this.#maxGetManyKeys = positive(options.maxGetManyKeys, DEFAULT_MAX_GET_MANY, "maxGetManyKeys", 1_000);
|
|
275
|
+
this.#maxParallelReads = positive(options.maxParallelReads, Math.min(DEFAULT_MAX_PARALLEL_READS, this.#maxGetManyKeys), "maxParallelReads", this.#maxGetManyKeys);
|
|
276
|
+
this.#maxQueryLimit = positive(options.maxQueryLimit, DEFAULT_MAX_QUERY_LIMIT, "maxQueryLimit", 1_000);
|
|
277
|
+
this.#maxQueryOffset = positive(options.maxQueryOffset, DEFAULT_MAX_QUERY_OFFSET, "maxQueryOffset", 10_000_000);
|
|
278
|
+
this.#maxQueryPages = positive(options.maxQueryPages, DEFAULT_MAX_QUERY_PAGES, "maxQueryPages", 1_000_000);
|
|
279
|
+
this.#maxDiscoveryRelations = positive(options.maxDiscoveryRelations, DEFAULT_MAX_DISCOVERY_RELATIONS, "maxDiscoveryRelations", 10_000);
|
|
280
|
+
this.#maxDiscoveryFields = positive(options.maxDiscoveryFieldsPerRelation, DEFAULT_MAX_DISCOVERY_FIELDS, "maxDiscoveryFieldsPerRelation", 10_000);
|
|
281
|
+
this.#maxDiscoveryBytes = positive(options.maxDiscoveryBytes, DEFAULT_MAX_DISCOVERY_BYTES, "maxDiscoveryBytes", 16_777_216);
|
|
282
|
+
this.#maxCursorBytes = positive(options.maxCursorBytes, DEFAULT_MAX_CURSOR_BYTES, "maxCursorBytes", 65_536);
|
|
283
|
+
this.#maxSchemaDepth = positive(options.maxSchemaDepth, DEFAULT_MAX_SCHEMA_DEPTH, "maxSchemaDepth", 16);
|
|
284
|
+
}
|
|
285
|
+
async get(key, codec) {
|
|
286
|
+
const url = this.keyUrl(key);
|
|
287
|
+
url.searchParams.set("select", "*");
|
|
288
|
+
url.searchParams.set("limit", "1");
|
|
289
|
+
const rows = await this.request(url, "GET", { accept: "application/json" }, undefined, (response, signal, cancelled) => this.rows(response, signal, cancelled));
|
|
290
|
+
const row = rows[0];
|
|
291
|
+
return row === undefined ? { key, exists: false } : this.record(row, key, codec);
|
|
292
|
+
}
|
|
293
|
+
async getMany(keys, codec) {
|
|
294
|
+
if (keys.length > this.#maxGetManyKeys)
|
|
295
|
+
throw new UnsupportedError(`PostgREST getMany above ${String(this.#maxGetManyKeys)} keys`);
|
|
296
|
+
const records = new Array(keys.length);
|
|
297
|
+
let next = 0;
|
|
298
|
+
const worker = async () => {
|
|
299
|
+
for (;;) {
|
|
300
|
+
const index = next;
|
|
301
|
+
next += 1;
|
|
302
|
+
const key = keys[index];
|
|
303
|
+
if (key === undefined)
|
|
304
|
+
return;
|
|
305
|
+
records[index] = await this.get(key, codec);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
await Promise.all(Array.from({ length: Math.min(this.#maxParallelReads, keys.length) }, worker));
|
|
309
|
+
return records;
|
|
310
|
+
}
|
|
311
|
+
async insert(key, data, codec) {
|
|
312
|
+
const payload = this.payload(key, data, codec);
|
|
313
|
+
try {
|
|
314
|
+
await this.request(this.relationUrl(key), "POST", this.writeHeaders("return=minimal"), payload, this.discard);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
if (error instanceof PostgrestHttpError && error.status === 409)
|
|
318
|
+
throw new AlreadyExistsError(key, { cause: error });
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async set(key, data, codec) {
|
|
323
|
+
const payload = this.payload(key, data, codec);
|
|
324
|
+
const rows = await this.request(this.keyUrl(key), "PUT", this.writeHeaders("return=representation"), payload, (response, signal, cancelled) => this.rows(response, signal, cancelled));
|
|
325
|
+
this.assertExactlyOne(rows);
|
|
326
|
+
}
|
|
327
|
+
async update(key, data) {
|
|
328
|
+
const payload = encodedRecord(data, identityCodec, this.#idColumn);
|
|
329
|
+
const rows = await this.request(this.keyUrl(key), "PATCH", this.writeHeaders("handling=strict, max-affected=1, return=representation"), payload, (response, signal, cancelled) => this.rows(response, signal, cancelled));
|
|
330
|
+
if (rows.length === 0)
|
|
331
|
+
throw new NotFoundError(key);
|
|
332
|
+
this.assertExactlyOne(rows);
|
|
333
|
+
}
|
|
334
|
+
async delete(key) {
|
|
335
|
+
const rows = await this.request(this.keyUrl(key), "DELETE", this.writeHeaders("handling=strict, max-affected=1, return=representation"), undefined, (response, signal, cancelled) => this.rows(response, signal, cancelled));
|
|
336
|
+
if (rows.length === 0)
|
|
337
|
+
return;
|
|
338
|
+
this.assertExactlyOne(rows);
|
|
339
|
+
}
|
|
340
|
+
async query(query) {
|
|
341
|
+
const { url, limit } = this.prepareQuery(query, false);
|
|
342
|
+
const rows = await this.request(url, "GET", { accept: "application/json" }, undefined, (response, signal, cancelled) => this.rows(response, signal, cancelled));
|
|
343
|
+
return this.queryPage(rows, limit, query);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Cancellable query entry point with an optional adapter-owned keyset cursor.
|
|
347
|
+
* The caller signal covers header acquisition, fetch and response-body
|
|
348
|
+
* reading, and shares one deadline with `query`.
|
|
349
|
+
*/
|
|
350
|
+
async queryWithSignal(query, signal) {
|
|
351
|
+
if (signal.aborted)
|
|
352
|
+
throw new QueryCancelledError("caller");
|
|
353
|
+
const { url, limit, continuation } = this.prepareQuery(query, true);
|
|
354
|
+
const rows = await this.request(url, "GET", { accept: "application/json" }, undefined, (response, readSignal, cancelled) => this.rows(response, readSignal, cancelled), signal);
|
|
355
|
+
if (signal.aborted)
|
|
356
|
+
throw new QueryCancelledError("caller");
|
|
357
|
+
return this.queryPage(rows, limit, query, continuation);
|
|
358
|
+
}
|
|
359
|
+
queryPage(rows, limit, query, continuation) {
|
|
360
|
+
if (rows.length > limit)
|
|
361
|
+
throw new RangeError("PostgREST response returned more rows than the requested query limit");
|
|
362
|
+
const records = rows.map((row) => this.record(row, undefined, query.source.codec, query.source.name));
|
|
363
|
+
// A short page is not proof of end-of-data: `db-max-rows` (or a proxy)
|
|
364
|
+
// can cap every response below the requested limit. Continue after every
|
|
365
|
+
// non-empty page and let the next request prove the end with an empty
|
|
366
|
+
// page. Only cursor mode continues; legacy query/offset keeps its exact
|
|
367
|
+
// single-request contract.
|
|
368
|
+
if (continuation === undefined || rows.length === 0)
|
|
369
|
+
return { records };
|
|
370
|
+
const last = rows[rows.length - 1];
|
|
371
|
+
if (last === undefined)
|
|
372
|
+
return { records };
|
|
373
|
+
const values = continuation.orders.map((order) => {
|
|
374
|
+
if (!Object.hasOwn(last, order.field)) {
|
|
375
|
+
throw new TypeError(`PostgREST response row is missing the ${order.field} order column`);
|
|
376
|
+
}
|
|
377
|
+
return last[order.field];
|
|
378
|
+
});
|
|
379
|
+
const cursor = encodePostgrestCursor(continuation.binding, continuation.page, values, this.#maxCursorBytes);
|
|
380
|
+
return { records, nextCursor: { values: [cursor] } };
|
|
381
|
+
}
|
|
382
|
+
prepareQuery(query, cursorMode) {
|
|
383
|
+
if (query.source.kind !== "collection" || query.source.parent !== undefined) {
|
|
384
|
+
throw new UnsupportedError("PostgREST collection-group or nested-collection queries");
|
|
385
|
+
}
|
|
386
|
+
for (const key of ["projection", "select", "columns", "fields"]) {
|
|
387
|
+
if (Object.hasOwn(query, key))
|
|
388
|
+
throw new UnsupportedError("PostgREST query projection");
|
|
389
|
+
}
|
|
390
|
+
if (query.startAt !== undefined || query.endAt !== undefined || query.endBefore !== undefined) {
|
|
391
|
+
throw new UnsupportedError("PostgREST DALgo query cursors");
|
|
392
|
+
}
|
|
393
|
+
if (!cursorMode && query.startAfter !== undefined) {
|
|
394
|
+
throw new UnsupportedError("PostgREST DALgo query cursors");
|
|
395
|
+
}
|
|
396
|
+
if (query.startAfter !== undefined && query.offset !== undefined) {
|
|
397
|
+
throw new UnsupportedError("PostgREST query cursors combined with offset");
|
|
398
|
+
}
|
|
399
|
+
const url = this.relationUrlForCollection(query.source.name);
|
|
400
|
+
url.searchParams.set("select", "*");
|
|
401
|
+
for (const filter of query.filters) {
|
|
402
|
+
const [field, value] = filterParameter(filter, this.#idColumn);
|
|
403
|
+
url.searchParams.append(field, value);
|
|
404
|
+
}
|
|
405
|
+
const orders = cursorMode ? this.cursorOrders(query.orders) : this.legacyOrders(query.orders);
|
|
406
|
+
if (orders.length > 0) {
|
|
407
|
+
url.searchParams.set("order", orders.map((order) => `${postgrestFieldKey(order.field)}.${order.direction}`).join(","));
|
|
408
|
+
}
|
|
409
|
+
const limit = query.limit ?? this.#maxQueryLimit;
|
|
410
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > this.#maxQueryLimit) {
|
|
411
|
+
throw new UnsupportedError(`PostgREST query limit above ${String(this.#maxQueryLimit)}`);
|
|
412
|
+
}
|
|
413
|
+
url.searchParams.set("limit", String(limit));
|
|
414
|
+
if (query.offset !== undefined) {
|
|
415
|
+
if (!Number.isSafeInteger(query.offset) || query.offset < 0) {
|
|
416
|
+
throw new TypeError("PostgREST query offset must be a non-negative safe integer");
|
|
417
|
+
}
|
|
418
|
+
if (query.offset > this.#maxQueryOffset) {
|
|
419
|
+
throw new UnsupportedError(`PostgREST query offset above ${String(this.#maxQueryOffset)}`);
|
|
420
|
+
}
|
|
421
|
+
url.searchParams.set("offset", String(query.offset));
|
|
422
|
+
}
|
|
423
|
+
if (!cursorMode)
|
|
424
|
+
return { url, limit };
|
|
425
|
+
const continuationBinding = this.cursorBinding(query, orders, limit);
|
|
426
|
+
let page = 1;
|
|
427
|
+
if (query.startAfter !== undefined) {
|
|
428
|
+
if (query.startAfter.values.length !== 1 || typeof query.startAfter.values[0] !== "string") {
|
|
429
|
+
throw new PostgrestCursorError("malformed");
|
|
430
|
+
}
|
|
431
|
+
const decoded = decodePostgrestCursor(query.startAfter.values[0], continuationBinding, this.#maxCursorBytes);
|
|
432
|
+
page = decoded.page;
|
|
433
|
+
if (page > this.#maxQueryPages) {
|
|
434
|
+
throw new UnsupportedError(`PostgREST query cursor page above ${String(this.#maxQueryPages)}`);
|
|
435
|
+
}
|
|
436
|
+
url.searchParams.set("or", postgrestKeysetFilter(orders, decoded.values));
|
|
437
|
+
}
|
|
438
|
+
return { url, limit, continuation: { binding: continuationBinding, page: page + 1, orders } };
|
|
439
|
+
}
|
|
440
|
+
cursorOrders(orders) {
|
|
441
|
+
const mapped = [];
|
|
442
|
+
const seen = new Set();
|
|
443
|
+
for (const order of orders) {
|
|
444
|
+
if (order.direction !== "asc" && order.direction !== "desc")
|
|
445
|
+
throw new UnsupportedError("PostgREST query order directions");
|
|
446
|
+
const field = order.field === DOCUMENT_ID ? this.#idColumn : identifier(String(order.field), "order field");
|
|
447
|
+
if (seen.has(field))
|
|
448
|
+
throw new UnsupportedError("PostgREST duplicate query order fields");
|
|
449
|
+
seen.add(field);
|
|
450
|
+
mapped.push({ field, direction: order.direction });
|
|
451
|
+
}
|
|
452
|
+
if (!seen.has(this.#idColumn))
|
|
453
|
+
mapped.push({ field: this.#idColumn, direction: "asc" });
|
|
454
|
+
return mapped;
|
|
455
|
+
}
|
|
456
|
+
legacyOrders(orders) {
|
|
457
|
+
return orders.map((order) => {
|
|
458
|
+
if (order.direction !== "asc" && order.direction !== "desc")
|
|
459
|
+
throw new UnsupportedError("PostgREST query order directions");
|
|
460
|
+
return {
|
|
461
|
+
field: order.field === DOCUMENT_ID ? this.#idColumn : identifier(String(order.field), "order field"),
|
|
462
|
+
direction: order.direction,
|
|
463
|
+
};
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
cursorBinding(query, orders, limit) {
|
|
467
|
+
return {
|
|
468
|
+
endpoint: this.#baseUrl,
|
|
469
|
+
collection: query.source.name,
|
|
470
|
+
relation: this.relationForCollection(query.source.name),
|
|
471
|
+
...(this.#schema === undefined ? {} : { schema: this.#schema }),
|
|
472
|
+
idColumn: this.#idColumn,
|
|
473
|
+
select: "*",
|
|
474
|
+
filters: query.filters.map((filter) => filterParameter(filter, this.#idColumn)),
|
|
475
|
+
orders,
|
|
476
|
+
limit,
|
|
477
|
+
...(this.#identityScope === undefined ? {} : { identityScope: this.#identityScope }),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
/** Read-only provider metadata. It never calls an administration endpoint. */
|
|
481
|
+
async discover(signal) {
|
|
482
|
+
if (signal?.aborted === true)
|
|
483
|
+
throw new QueryCancelledError("caller");
|
|
484
|
+
try {
|
|
485
|
+
const body = await this.request(new URL(this.#baseUrl), "GET", { accept: "application/openapi+json, application/json;q=0.9" }, undefined, (response, readSignal, cancelled) => this.metadataBody(response, readSignal, cancelled), signal);
|
|
486
|
+
const parsed = parsePostgrestOpenApi(body, {
|
|
487
|
+
idColumn: this.#idColumn,
|
|
488
|
+
maxRelations: this.#maxDiscoveryRelations,
|
|
489
|
+
maxFieldsPerRelation: this.#maxDiscoveryFields,
|
|
490
|
+
maxSchemaDepth: this.#maxSchemaDepth,
|
|
491
|
+
});
|
|
492
|
+
if (parsed.status === "unavailable") {
|
|
493
|
+
return this.discoveryUnavailable(parsed.reason, parsed.detail, 200);
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
adapter: "@dalgo/dalgo2postgrest",
|
|
497
|
+
protocol: "postgrest-openapi",
|
|
498
|
+
endpoint: this.#baseUrl,
|
|
499
|
+
...(this.#schema === undefined ? {} : { schema: this.#schema }),
|
|
500
|
+
status: "available",
|
|
501
|
+
detail: "read-only OpenAPI metadata is available for the request role",
|
|
502
|
+
relations: parsed.relations,
|
|
503
|
+
relationCount: parsed.relationCount,
|
|
504
|
+
truncated: parsed.truncated,
|
|
505
|
+
omittedRelations: parsed.omittedRelations,
|
|
506
|
+
bounds: this.discoveryBounds,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
if (error instanceof QueryCancelledError)
|
|
511
|
+
throw error;
|
|
512
|
+
if (error instanceof PostgrestHttpError) {
|
|
513
|
+
if (error.status === 401) {
|
|
514
|
+
return this.discoveryUnavailable("unauthorized", "PostgREST metadata requires a valid user identity", error.status);
|
|
515
|
+
}
|
|
516
|
+
if (error.status === 403) {
|
|
517
|
+
return this.discoveryUnavailable("forbidden", "PostgREST metadata is not permitted for this identity", error.status);
|
|
518
|
+
}
|
|
519
|
+
if (error.status === 404 || error.status === 405) {
|
|
520
|
+
return this.discoveryUnavailable("metadata-disabled", "PostgREST OpenAPI metadata is disabled or not available at this endpoint", error.status);
|
|
521
|
+
}
|
|
522
|
+
return this.discoveryUnavailable("request-failed", "PostgREST metadata request failed", error.status);
|
|
523
|
+
}
|
|
524
|
+
return this.discoveryUnavailable("request-failed", "PostgREST metadata request could not be completed");
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/** A safe UI-factory preflight: proves transport, headers and metadata access. */
|
|
528
|
+
async testConnection(signal) {
|
|
529
|
+
const discovery = await this.discover(signal);
|
|
530
|
+
if (discovery.status === "available") {
|
|
531
|
+
return {
|
|
532
|
+
ok: true,
|
|
533
|
+
status: "connected",
|
|
534
|
+
relationCount: discovery.relationCount,
|
|
535
|
+
...(this.#schema === undefined ? {} : { schema: this.#schema }),
|
|
536
|
+
detail: "PostgREST connection, identity and metadata access succeeded",
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
if (discovery.reason === "no-relations") {
|
|
540
|
+
return {
|
|
541
|
+
ok: true,
|
|
542
|
+
status: "connected",
|
|
543
|
+
relationCount: 0,
|
|
544
|
+
...(this.#schema === undefined ? {} : { schema: this.#schema }),
|
|
545
|
+
detail: discovery.detail,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const status = discovery.reason === "unauthorized"
|
|
549
|
+
? "unauthorized"
|
|
550
|
+
: discovery.reason === "forbidden"
|
|
551
|
+
? "forbidden"
|
|
552
|
+
: discovery.reason === "metadata-disabled"
|
|
553
|
+
? "metadata-disabled"
|
|
554
|
+
: discovery.reason === "malformed-metadata"
|
|
555
|
+
? "metadata-malformed"
|
|
556
|
+
: "unreachable";
|
|
557
|
+
return {
|
|
558
|
+
ok: false,
|
|
559
|
+
status,
|
|
560
|
+
...(discovery.httpStatus === undefined ? {} : { httpStatus: discovery.httpStatus }),
|
|
561
|
+
...(this.#schema === undefined ? {} : { schema: this.#schema }),
|
|
562
|
+
detail: discovery.detail,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
get discoveryBounds() {
|
|
566
|
+
return {
|
|
567
|
+
maxRelations: this.#maxDiscoveryRelations,
|
|
568
|
+
maxFieldsPerRelation: this.#maxDiscoveryFields,
|
|
569
|
+
maxMetadataBytes: this.#maxDiscoveryBytes,
|
|
570
|
+
maxSchemaDepth: this.#maxSchemaDepth,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
discoveryUnavailable(reason, detail, httpStatus) {
|
|
574
|
+
return {
|
|
575
|
+
adapter: "@dalgo/dalgo2postgrest",
|
|
576
|
+
protocol: "postgrest-openapi",
|
|
577
|
+
endpoint: this.#baseUrl,
|
|
578
|
+
...(this.#schema === undefined ? {} : { schema: this.#schema }),
|
|
579
|
+
status: "unavailable",
|
|
580
|
+
reason,
|
|
581
|
+
detail,
|
|
582
|
+
...(httpStatus === undefined ? {} : { httpStatus }),
|
|
583
|
+
relations: [],
|
|
584
|
+
relationCount: 0,
|
|
585
|
+
truncated: false,
|
|
586
|
+
omittedRelations: 0,
|
|
587
|
+
bounds: this.discoveryBounds,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
metadataBody = async (response, signal, cancelled) => {
|
|
591
|
+
const body = await readJson(response, this.#maxDiscoveryBytes, signal, cancelled);
|
|
592
|
+
if (body === undefined)
|
|
593
|
+
throw new TypeError("PostgREST metadata response was empty");
|
|
594
|
+
return body;
|
|
595
|
+
};
|
|
596
|
+
async runReadwriteTransaction(callback) {
|
|
597
|
+
void callback;
|
|
598
|
+
throw new UnsupportedError("PostgREST callback transactions");
|
|
599
|
+
}
|
|
600
|
+
relationUrl(key) {
|
|
601
|
+
if (key.parent !== undefined)
|
|
602
|
+
throw new UnsupportedError("PostgREST nested DALgo keys");
|
|
603
|
+
return this.relationUrlForCollection(key.collection);
|
|
604
|
+
}
|
|
605
|
+
relationForCollection(collection) {
|
|
606
|
+
return identifier(this.#relationName(collection), "relation");
|
|
607
|
+
}
|
|
608
|
+
relationUrlForCollection(collection) {
|
|
609
|
+
return new URL(encodeURIComponent(this.relationForCollection(collection)), this.#baseUrl);
|
|
610
|
+
}
|
|
611
|
+
keyUrl(key) {
|
|
612
|
+
const url = this.relationUrl(key);
|
|
613
|
+
url.searchParams.set(postgrestFieldKey(this.#idColumn), `eq.${scalar(key.id)}`);
|
|
614
|
+
return url;
|
|
615
|
+
}
|
|
616
|
+
payload(key, data, codec) {
|
|
617
|
+
if (typeof key.id === "number" && (!Number.isFinite(key.id) || Object.is(key.id, -0) || isUnsafeNativeNumber(key.id))) {
|
|
618
|
+
throw new TypeError("PostgREST write keys must have a finite, lossless number or string ID");
|
|
619
|
+
}
|
|
620
|
+
return { [this.#idColumn]: key.id, ...encodedRecord(data, codecOrIdentity(codec), this.#idColumn) };
|
|
621
|
+
}
|
|
622
|
+
writeHeaders(prefer) {
|
|
623
|
+
return { accept: "application/json", "content-type": "application/json", prefer };
|
|
624
|
+
}
|
|
625
|
+
profileHeaders(method) {
|
|
626
|
+
if (this.#schema === undefined)
|
|
627
|
+
return {};
|
|
628
|
+
return method === "GET" || method === "HEAD"
|
|
629
|
+
? { "accept-profile": this.#schema }
|
|
630
|
+
: { "content-profile": this.#schema };
|
|
631
|
+
}
|
|
632
|
+
discard = async (response) => {
|
|
633
|
+
cancelQuietly(response.body);
|
|
634
|
+
};
|
|
635
|
+
async request(url, method, requiredHeaders, body, consume, callerSignal) {
|
|
636
|
+
const text = body === undefined ? undefined : JSON.stringify(body);
|
|
637
|
+
if (text !== undefined && new TextEncoder().encode(text).byteLength > this.#maxRequestBytes)
|
|
638
|
+
throw new RangeError("PostgREST request exceeds maxRequestBytes");
|
|
639
|
+
const scope = createAbortScope(callerSignal, this.#timeoutMs);
|
|
640
|
+
const cancelled = () => callerSignal === undefined
|
|
641
|
+
? new PostgrestRequestError()
|
|
642
|
+
: new QueryCancelledError(scope.deadlineExceeded ? "deadline" : "caller");
|
|
643
|
+
let response;
|
|
644
|
+
try {
|
|
645
|
+
let provided;
|
|
646
|
+
try {
|
|
647
|
+
provided = await raceAbort(Promise.resolve().then(() => typeof this.#headers === "function" ? this.#headers() : this.#headers), scope.signal, cancelled);
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
if (scope.signal.aborted)
|
|
651
|
+
throw cancelled();
|
|
652
|
+
throw new PostgrestRequestError();
|
|
653
|
+
}
|
|
654
|
+
if (provided !== undefined)
|
|
655
|
+
validateHeaders(provided);
|
|
656
|
+
const headers = new Headers(provided);
|
|
657
|
+
for (const [name, value] of Object.entries(this.profileHeaders(method)))
|
|
658
|
+
headers.set(name, value);
|
|
659
|
+
for (const [name, value] of Object.entries(requiredHeaders))
|
|
660
|
+
headers.set(name, value);
|
|
661
|
+
if (scope.signal.aborted)
|
|
662
|
+
throw cancelled();
|
|
663
|
+
const pending = Promise.resolve().then(() => this.#fetch(url, {
|
|
664
|
+
method,
|
|
665
|
+
headers,
|
|
666
|
+
redirect: "error",
|
|
667
|
+
...(text === undefined ? {} : { body: text }),
|
|
668
|
+
signal: scope.signal,
|
|
669
|
+
}));
|
|
670
|
+
void pending.then((late) => { if (scope.signal.aborted)
|
|
671
|
+
cancelQuietly(late.body); }, () => undefined);
|
|
672
|
+
const responseValue = await raceAbort(pending, scope.signal, cancelled);
|
|
673
|
+
response = responseValue;
|
|
674
|
+
if (scope.signal.aborted)
|
|
675
|
+
throw cancelled();
|
|
676
|
+
if (!responseValue.ok) {
|
|
677
|
+
cancelQuietly(responseValue.body);
|
|
678
|
+
throw new PostgrestHttpError(responseValue.status);
|
|
679
|
+
}
|
|
680
|
+
const value = await consume(responseValue, scope.signal, cancelled);
|
|
681
|
+
if (scope.signal.aborted)
|
|
682
|
+
throw cancelled();
|
|
683
|
+
return value;
|
|
684
|
+
}
|
|
685
|
+
catch (error) {
|
|
686
|
+
if (scope.signal.aborted) {
|
|
687
|
+
cancelQuietly(response?.body);
|
|
688
|
+
throw cancelled();
|
|
689
|
+
}
|
|
690
|
+
if (error instanceof PostgrestHttpError || error instanceof PostgrestRequestError || error instanceof QueryCancelledError)
|
|
691
|
+
throw error;
|
|
692
|
+
throw new PostgrestRequestError();
|
|
693
|
+
}
|
|
694
|
+
finally {
|
|
695
|
+
scope.dispose();
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
async rows(response, signal, cancelled) {
|
|
699
|
+
const body = await readJson(response, this.#maxResponseBytes, signal, cancelled);
|
|
700
|
+
if (!Array.isArray(body) || !body.every(isPlainObject))
|
|
701
|
+
throw new TypeError("PostgREST response must be a JSON array of row objects");
|
|
702
|
+
return body;
|
|
703
|
+
}
|
|
704
|
+
assertExactlyOne(rows) {
|
|
705
|
+
if (rows.length !== 1)
|
|
706
|
+
throw new PostgrestRequestError();
|
|
707
|
+
}
|
|
708
|
+
record(row, requestedKey, codec, collection) {
|
|
709
|
+
const id = row[this.#idColumn];
|
|
710
|
+
if ((typeof id !== "string" && typeof id !== "number")
|
|
711
|
+
|| (typeof id === "number" && (!Number.isFinite(id) || Object.is(id, -0) || isUnsafeNativeNumber(id)))) {
|
|
712
|
+
throw new TypeError(`PostgREST response row is missing a lossless ${this.#idColumn} column`);
|
|
713
|
+
}
|
|
714
|
+
let key;
|
|
715
|
+
if (requestedKey !== undefined) {
|
|
716
|
+
key = requestedKey;
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
if (collection === undefined)
|
|
720
|
+
throw new Error("PostgREST query record collection is required");
|
|
721
|
+
key = new Key(collection, id);
|
|
722
|
+
}
|
|
723
|
+
if (requestedKey !== undefined && !Object.is(requestedKey.id, id))
|
|
724
|
+
throw new TypeError("PostgREST response row does not match requested DALgo key");
|
|
725
|
+
const data = { ...row };
|
|
726
|
+
delete data[this.#idColumn];
|
|
727
|
+
return { key, exists: true, data: codecOrIdentity(codec).decode(data) };
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
//# sourceMappingURL=database.js.map
|