@terrascale/sdk 0.0.5 → 0.0.7
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/CHANGELOG.md +27 -0
- package/README.md +99 -12
- package/package.json +1 -1
- package/sdk-public-v1-route-manifest.json +6 -1
- package/sdk-v1-route-manifest.json +39 -36
- package/src/index.js +21 -0
- package/src/local/router.js +12 -3
- package/src/v1/change-webhooks.js +78 -0
- package/src/v1/changes.js +2 -2
- package/src/v1/decode.js +4 -3
- package/src/v1/errors.js +26 -0
- package/src/v1/export.js +3 -3
- package/src/v1/filter.js +103 -2
- package/src/v1/ids.js +7 -2
- package/src/v1/indexes.js +6 -4
- package/src/v1/management-routes.js +12 -4
- package/src/v1/shape-reader.js +19 -5
- package/src/v1/shape-replica.js +46 -0
- package/src/v1/shape-subscription.js +57 -5
- package/src/v1/shapes.js +3 -3
- package/src/v1/sql-execute.js +132 -0
- package/src/v1/sql.js +2 -2
- package/src/v1/terrabase-v1.js +141 -9
- package/src/v1/transport.js +27 -5
- package/types/index.d.ts +21 -1
- package/types/v1/change-webhooks.d.ts +59 -0
- package/types/v1/decode.d.ts +7 -2
- package/types/v1/errors.d.ts +18 -0
- package/types/v1/filter.d.ts +50 -0
- package/types/v1/ids.d.ts +3 -1
- package/types/v1/indexes.d.ts +5 -0
- package/types/v1/management-routes.d.ts +1 -1
- package/types/v1/shape-replica.d.ts +8 -0
- package/types/v1/shape-subscription.d.ts +15 -0
- package/types/v1/sql-execute.d.ts +61 -0
- package/types/v1/terrabase-v1.d.ts +95 -1
- package/types/v1/transport.d.ts +17 -0
|
@@ -3,6 +3,7 @@ import { TerraBaseV1Error } from "./errors.js";
|
|
|
3
3
|
import { requireFilter } from "./filter.js";
|
|
4
4
|
import { parseExactJson, stringifyExactJson } from "./json.js";
|
|
5
5
|
import { readShapePage } from "./shape-reader.js";
|
|
6
|
+
import { BodyTransportError, RequestTransportError } from "./transport.js";
|
|
6
7
|
/**
|
|
7
8
|
* @import { JsonObject } from "./json.js"
|
|
8
9
|
* @import { V1Filter } from "./filter.js"
|
|
@@ -12,6 +13,14 @@ import { readShapePage } from "./shape-reader.js";
|
|
|
12
13
|
* @typedef {object} V1ShapeSubscribeOptions
|
|
13
14
|
* @property {AbortSignal | undefined} [signal] Closes the subscription when aborted; cancellation settles `done`.
|
|
14
15
|
* @property {((subscription: V1ShapeSubscription) => void) | undefined} [onUpdate] Runs after validated responses and freshness changes; throwing rejects `done` unless already closed or aborted.
|
|
16
|
+
* @property {V1ShapeReplicaStore | undefined} [replica] Keeps the last resumable state for reads across restarts and while offline; writes still go to the server.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Holds one subscription's state as exact JSON text, e.g. in IndexedDB or React Native storage. Scope one store
|
|
20
|
+
* to one signed-in subject: the replica holds that subject's authorized documents. A failing store rejects `done`.
|
|
21
|
+
* @typedef {object} V1ShapeReplicaStore
|
|
22
|
+
* @property {() => string | undefined | Promise<string | undefined>} load
|
|
23
|
+
* @property {(text: string | undefined) => void | Promise<void>} save Receives undefined after must_refetch discards state.
|
|
15
24
|
*/
|
|
16
25
|
/** @typedef {{ readonly version: string; readonly document: JsonObject }} V1ShapeDocument */
|
|
17
26
|
const retryBaseMilliseconds = 250;
|
|
@@ -81,6 +90,9 @@ export class V1ShapeSubscription {
|
|
|
81
90
|
/** @type {V1Filter | undefined} */
|
|
82
91
|
#where;
|
|
83
92
|
#onUpdate;
|
|
93
|
+
#replica;
|
|
94
|
+
/** @type {string} */
|
|
95
|
+
#scope;
|
|
84
96
|
#external;
|
|
85
97
|
#onExternalAbort = () => this.close();
|
|
86
98
|
#done;
|
|
@@ -92,9 +104,11 @@ export class V1ShapeSubscription {
|
|
|
92
104
|
*/
|
|
93
105
|
constructor(transport, params, shape = {}, options = {}) {
|
|
94
106
|
requireMembers(shape, ["where"], "shape");
|
|
95
|
-
requireMembers(options, ["signal", "onUpdate"], "shape subscribe option");
|
|
107
|
+
requireMembers(options, ["signal", "onUpdate", "replica"], "shape subscribe option");
|
|
96
108
|
const { where } = shape;
|
|
97
|
-
const { signal, onUpdate } = options;
|
|
109
|
+
const { signal, onUpdate, replica } = options;
|
|
110
|
+
if (replica !== undefined && (typeof replica?.load !== "function" || typeof replica.save !== "function"))
|
|
111
|
+
throw new TypeError("replica has load and save functions.");
|
|
98
112
|
if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError("signal is an AbortSignal.");
|
|
99
113
|
if (onUpdate !== undefined && typeof onUpdate !== "function") throw new TypeError("onUpdate is a function.");
|
|
100
114
|
this.#transport = transport;
|
|
@@ -102,6 +116,9 @@ export class V1ShapeSubscription {
|
|
|
102
116
|
// Exact copy: a structuredClone would lose the source text of large or fractional JSON numbers.
|
|
103
117
|
this.#where = where === undefined ? undefined : requireFilter(parseExactJson(stringifyExactJson(requireFilter(where), "shape filter")));
|
|
104
118
|
this.#onUpdate = onUpdate;
|
|
119
|
+
this.#replica = replica;
|
|
120
|
+
// A replica restores only the identical shape it was saved from.
|
|
121
|
+
this.#scope = stringifyExactJson([this.#params.database, this.#params.collection, this.#where ?? null], "shape scope");
|
|
105
122
|
this.#external = signal;
|
|
106
123
|
if (signal?.aborted === true) this.close();
|
|
107
124
|
else signal?.addEventListener("abort", this.#onExternalAbort);
|
|
@@ -120,6 +137,7 @@ export class V1ShapeSubscription {
|
|
|
120
137
|
const signal = this.#controller.signal;
|
|
121
138
|
let retries = 0;
|
|
122
139
|
try {
|
|
140
|
+
if (this.#replica !== undefined) await this.#restore(signal);
|
|
123
141
|
for (;;) {
|
|
124
142
|
signal.throwIfAborted();
|
|
125
143
|
const request = this.#request();
|
|
@@ -127,18 +145,22 @@ export class V1ShapeSubscription {
|
|
|
127
145
|
try {
|
|
128
146
|
page = await readShapePage(this.#transport, this.#params, request, signal);
|
|
129
147
|
} catch (error) {
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
// Shape reads are replay-safe GETs: a lost connection keeps state and resumes at the same position.
|
|
149
|
+
const lost = error instanceof RequestTransportError || error instanceof BodyTransportError;
|
|
150
|
+
if (lost || (error instanceof TerraBaseV1Error && isRetryable(error))) {
|
|
132
151
|
retries = Math.min(retries + 1, maximumBackoffAttempt);
|
|
133
|
-
await sleep(retryDelay(retries, error.retryAfter), signal);
|
|
152
|
+
await sleep(retryDelay(retries, lost ? undefined : error.retryAfter), signal);
|
|
134
153
|
continue;
|
|
135
154
|
}
|
|
155
|
+
if (!(error instanceof TerraBaseV1Error)) throw error;
|
|
136
156
|
if (error.status !== 409 || error.code !== "must_refetch") throw error;
|
|
137
157
|
this.#recover(request, error);
|
|
158
|
+
if (this.#handle === undefined) await this.#replica?.save(undefined);
|
|
138
159
|
continue;
|
|
139
160
|
}
|
|
140
161
|
retries = 0;
|
|
141
162
|
this.#commit(page);
|
|
163
|
+
await this.#persist(signal);
|
|
142
164
|
this.#onUpdate?.(this);
|
|
143
165
|
}
|
|
144
166
|
} catch (error) {
|
|
@@ -188,6 +210,36 @@ export class V1ShapeSubscription {
|
|
|
188
210
|
this.#resumeAttempt = false;
|
|
189
211
|
this.#onUpdate?.(this);
|
|
190
212
|
}
|
|
213
|
+
/** @param {AbortSignal} signal */
|
|
214
|
+
async #restore(signal) {
|
|
215
|
+
const text = await this.#replica?.load();
|
|
216
|
+
signal.throwIfAborted();
|
|
217
|
+
if (text === undefined) return;
|
|
218
|
+
/** @type {any} */
|
|
219
|
+
let saved;
|
|
220
|
+
try { saved = parseExactJson(text); } catch { saved = undefined; }
|
|
221
|
+
// Unknown or other-shape replicas are discarded, never merged.
|
|
222
|
+
if (saved?.format !== 1 || saved.scope !== this.#scope || typeof saved.handle !== "string" || typeof saved.offset !== "string" ||
|
|
223
|
+
!/^(?:0|[1-9][0-9]*)$/u.test(saved.offset) || !Array.isArray(saved.documents) ||
|
|
224
|
+
!saved.documents.every((/** @type {any} */ item) => Array.isArray(item) && typeof item[0] === "string" &&
|
|
225
|
+
typeof item[1]?.version === "string" && typeof item[1].document === "object" && item[1].document !== null)) return;
|
|
226
|
+
this.#documents = new Map(saved.documents.map((/** @type {[string, V1ShapeDocument]} */ [id, entry]) => [id, deepFreeze({ version: entry.version, document: entry.document })]));
|
|
227
|
+
this.#handle = saved.handle;
|
|
228
|
+
this.#offset = saved.offset;
|
|
229
|
+
this.#cursor = typeof saved.cursor === "string" ? saved.cursor : undefined;
|
|
230
|
+
this.#resume = typeof saved.resume === "string" ? saved.resume : undefined;
|
|
231
|
+
// Restored state is stale until the server confirms a fresh head.
|
|
232
|
+
this.#upToDate = false;
|
|
233
|
+
this.#onUpdate?.(this);
|
|
234
|
+
}
|
|
235
|
+
/** @param {AbortSignal} signal */
|
|
236
|
+
async #persist(signal) {
|
|
237
|
+
// Snapshot pages are not resumable positions; keep the previous replica until the snapshot completes.
|
|
238
|
+
if (this.#replica === undefined || this.#handle === undefined || this.#offset.startsWith("snap.")) return;
|
|
239
|
+
await this.#replica.save(stringifyExactJson({ format: 1, scope: this.#scope, handle: this.#handle, offset: this.#offset,
|
|
240
|
+
cursor: this.#cursor ?? null, resume: this.#resume ?? null, documents: [...this.#documents] }, "shape replica"));
|
|
241
|
+
signal.throwIfAborted();
|
|
242
|
+
}
|
|
191
243
|
/** @param {import("./shapes.js").V1ShapePage} page */
|
|
192
244
|
#commit(page) {
|
|
193
245
|
// Repeats are applied, never skipped: a repeated event can carry a newer materialized value.
|
package/src/v1/shapes.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Current C# GET collection/shape contract (docs/shape-reads.md).
|
|
2
2
|
import { array, boolean, documentObject, malformed, object, string, u64 } from "./decode.js";
|
|
3
3
|
import { requireFilter } from "./filter.js";
|
|
4
|
-
import { parseU64,
|
|
4
|
+
import { parseU64, requireStoredId, requireVersion } from "./ids.js";
|
|
5
5
|
import { stringifyExactJson } from "./json.js";
|
|
6
6
|
/**
|
|
7
7
|
* @import { JsonObject, JsonValue } from "./json.js"
|
|
@@ -56,7 +56,7 @@ export function requireShapeOffset(value) {
|
|
|
56
56
|
const bytes = Uint8Array.from(atob(encoded.replace(/-/gu, "+").replace(/_/gu, "/")), c => c.charCodeAt(0));
|
|
57
57
|
const id = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
58
58
|
const canonical = btoa(String.fromCharCode(...new TextEncoder().encode(id))).replace(/\+/gu, "-").replace(/\//gu, "_").replace(/=+$/u, "");
|
|
59
|
-
if (canonical === encoded) {
|
|
59
|
+
if (canonical === encoded) { requireStoredId(id); return value; }
|
|
60
60
|
} catch { /* Invalid base64url or UTF-8. */ }
|
|
61
61
|
}
|
|
62
62
|
throw new TypeError("A shape offset is -1, a signed-64-bit decimal log position, or snap.<head>.<canonical-base64url-id>.");
|
|
@@ -108,7 +108,7 @@ export function decodeShapePage(value, headers) {
|
|
|
108
108
|
const change = object(value, "shape change");
|
|
109
109
|
return Object.freeze({ offset: u64(change.offset, "shape change offset"), ops: Object.freeze(array(change.ops, "shape operations").map(value => {
|
|
110
110
|
const op = object(value, "shape operation");
|
|
111
|
-
const id =
|
|
111
|
+
const id = requireStoredId(op.id);
|
|
112
112
|
if (op.op === "delete") return Object.freeze({ op: /** @type {const} */ ("delete"), id });
|
|
113
113
|
if (op.op !== "upsert") malformed("shape operation kind");
|
|
114
114
|
return Object.freeze({ op: /** @type {const} */ ("upsert"), id, version: requireVersion(op.version), document: documentObject(op.document) });
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { array, malformed, object, string } from "./decode.js";
|
|
2
|
+
import { requireName } from "./ids.js";
|
|
3
|
+
import { ExactDecimal, stringifyExactJson } from "./json.js";
|
|
4
|
+
/**
|
|
5
|
+
* @import { JsonValue } from "./json.js"
|
|
6
|
+
* @import { V1RequestOptions } from "./terrabase-v1.js"
|
|
7
|
+
* @import { V1Transport } from "./transport.js"
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An argument: null, a finite number, an int64 `bigint` or ExactDecimal, a boolean (stored as 1 or 0) or a string.
|
|
12
|
+
* @typedef {null | boolean | number | bigint | ExactDecimal | string} V1SqlArgument
|
|
13
|
+
*/
|
|
14
|
+
/** @typedef {readonly V1SqlArgument[] | Readonly<Record<string, V1SqlArgument>>} V1SqlArguments Positional array, or named values keyed without their `:`, `@` or `$` prefix. */
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} V1SqlStatement
|
|
17
|
+
* @property {string} sql
|
|
18
|
+
* @property {readonly V1SqlArgument[] | undefined} [args] Positional arguments.
|
|
19
|
+
* @property {Readonly<Record<string, V1SqlArgument>> | undefined} [named] Named arguments, keys without their prefix.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* A result value. Integers beyond 2^53 decode as ExactDecimal, keeping every digit; `BigInt(String(value))` converts one.
|
|
23
|
+
* @typedef {null | number | ExactDecimal | string} V1SqlValue
|
|
24
|
+
*/
|
|
25
|
+
/** @typedef {{ readonly name: string; readonly decltype: string | null }} V1SqlColumn A column; `decltype` is null for an expression. */
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} V1SqlExecuteResult
|
|
28
|
+
* @property {readonly V1SqlColumn[]} columns
|
|
29
|
+
* @property {readonly (readonly V1SqlValue[])[]} rows One value per column.
|
|
30
|
+
* @property {number} rowsAffected
|
|
31
|
+
* @property {number | ExactDecimal | null} lastInsertRowid
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const maximumStatements = 256;
|
|
35
|
+
const maximumSqlLength = 16_384;
|
|
36
|
+
|
|
37
|
+
/** @param {unknown} value @param {string} where */
|
|
38
|
+
function requireArgument(value, where) {
|
|
39
|
+
if (value === null || typeof value === "boolean" || typeof value === "string" || value instanceof ExactDecimal) return;
|
|
40
|
+
if (typeof value === "number" && Number.isFinite(value)) return;
|
|
41
|
+
if (typeof value === "bigint" && BigInt.asIntN(64, value) === value) return;
|
|
42
|
+
throw new TypeError(`SQL ${where} is null, a finite number, an int64 bigint, a boolean or a string.`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {unknown} statement @param {number} index */
|
|
46
|
+
function encodeStatement(statement, index) {
|
|
47
|
+
if (typeof statement !== "string" && (typeof statement !== "object" || statement === null || Array.isArray(statement)))
|
|
48
|
+
throw new TypeError(`SQL statement ${index} is a string or { sql, args, named }.`);
|
|
49
|
+
const { sql, args, named, ...extra } = /** @type {V1SqlStatement} */ (typeof statement === "string" ? { sql: statement } : statement);
|
|
50
|
+
for (const member of Object.keys(extra)) throw new TypeError(`Unknown SQL statement member ${member}.`);
|
|
51
|
+
if (typeof sql !== "string" || sql.length === 0 || sql.length > maximumSqlLength)
|
|
52
|
+
throw new TypeError(`SQL statement ${index} is a non-empty string of at most ${maximumSqlLength} UTF-16 code units.`);
|
|
53
|
+
/** @type {{ sql: string; args?: V1SqlArgument[]; named?: Record<string, V1SqlArgument> }} */
|
|
54
|
+
const encoded = { sql };
|
|
55
|
+
if (args !== undefined) {
|
|
56
|
+
if (!Array.isArray(args)) throw new TypeError(`SQL statement ${index} args is an array.`);
|
|
57
|
+
args.forEach((arg, position) => requireArgument(arg, `argument ${position}`));
|
|
58
|
+
encoded.args = [...args];
|
|
59
|
+
}
|
|
60
|
+
if (named !== undefined) {
|
|
61
|
+
if (named === null || typeof named !== "object" || Array.isArray(named)) throw new TypeError(`SQL statement ${index} named is an object.`);
|
|
62
|
+
for (const [name, value] of Object.entries(named)) {
|
|
63
|
+
if (/^[:@$]/u.test(name)) throw new TypeError(`SQL named argument ${name} is keyed without its prefix.`);
|
|
64
|
+
requireArgument(value, `named argument ${name}`);
|
|
65
|
+
}
|
|
66
|
+
encoded.named = { ...named };
|
|
67
|
+
}
|
|
68
|
+
return encoded;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @param {string} sql @param {V1SqlArguments | undefined} args @returns {V1SqlStatement} */
|
|
72
|
+
export function sqlStatement(sql, args) {
|
|
73
|
+
if (args === undefined) return { sql };
|
|
74
|
+
return Array.isArray(args) ? { sql, args } : { sql, named: /** @type {Readonly<Record<string, V1SqlArgument>>} */ (args) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {JsonValue | undefined} value @returns {V1SqlValue} */
|
|
78
|
+
function decodeValue(value) {
|
|
79
|
+
if (value === null || typeof value === "number" || typeof value === "string" || value instanceof ExactDecimal) return value;
|
|
80
|
+
return malformed("SQL value");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @param {JsonValue | undefined} value @returns {V1SqlExecuteResult} */
|
|
84
|
+
function decodeResult(value) {
|
|
85
|
+
const raw = object(value, "SQL statement result");
|
|
86
|
+
const columns = array(raw.columns, "SQL columns").map(entry => {
|
|
87
|
+
const column = object(entry, "SQL column");
|
|
88
|
+
const decltype = column.decltype === null || column.decltype === undefined ? null : string(column.decltype, "SQL column decltype");
|
|
89
|
+
return Object.freeze({ name: string(column.name, "SQL column name"), decltype });
|
|
90
|
+
});
|
|
91
|
+
const rows = array(raw.rows, "SQL rows").map(entry => {
|
|
92
|
+
const row = array(entry, "SQL row").map(decodeValue);
|
|
93
|
+
if (row.length !== columns.length) malformed("SQL row width");
|
|
94
|
+
return Object.freeze(row);
|
|
95
|
+
});
|
|
96
|
+
const affected = raw.rows_affected;
|
|
97
|
+
if (typeof affected !== "number" || !Number.isSafeInteger(affected) || affected < 0) malformed("SQL rows_affected");
|
|
98
|
+
const rowid = raw.last_insert_rowid ?? null;
|
|
99
|
+
if (rowid !== null && !(rowid instanceof ExactDecimal) && !(typeof rowid === "number" && Number.isInteger(rowid))) malformed("SQL last_insert_rowid");
|
|
100
|
+
return Object.freeze({ columns: Object.freeze(columns), rows: Object.freeze(rows), rowsAffected: affected, lastInsertRowid: rowid });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Runs 1 to 256 statements as one transaction: all commit or none do. BEGIN, COMMIT and ROLLBACK are refused.
|
|
105
|
+
* A refused statement throws SqlError with its index.
|
|
106
|
+
* @param {V1Transport} transport
|
|
107
|
+
* @param {string} database
|
|
108
|
+
* @param {readonly (string | V1SqlStatement)[]} statements
|
|
109
|
+
* @param {V1RequestOptions} [options]
|
|
110
|
+
* @returns {Promise<readonly V1SqlExecuteResult[]>}
|
|
111
|
+
*/
|
|
112
|
+
export async function executeSql(transport, database, statements, options = {}) {
|
|
113
|
+
if (!Array.isArray(statements) || statements.length === 0 || statements.length > maximumStatements)
|
|
114
|
+
throw new TypeError(`SQL batches hold 1 to ${maximumStatements} statements.`);
|
|
115
|
+
if (options === null || typeof options !== "object" || Array.isArray(options)) throw new TypeError("SQL request options must be an object.");
|
|
116
|
+
for (const member of Object.keys(options)) if (member !== "signal") throw new TypeError(`Unknown SQL request option ${member}.`);
|
|
117
|
+
const body = stringifyExactJson({ statements: statements.map(encodeStatement) }, "SQL statements");
|
|
118
|
+
const response = await transport.call("executeSql", { database: requireName("database", database) }, { body, signal: options.signal });
|
|
119
|
+
const results = array(object(response.value, "SQL execute response").results, "SQL results").map(decodeResult);
|
|
120
|
+
if (results.length !== statements.length) malformed("SQL result count");
|
|
121
|
+
return Object.freeze(results);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Rows as objects keyed by column name; a later duplicate column name wins.
|
|
126
|
+
* @template [T=Record<string, V1SqlValue>]
|
|
127
|
+
* @param {V1SqlExecuteResult} result
|
|
128
|
+
* @returns {T[]}
|
|
129
|
+
*/
|
|
130
|
+
export function sqlRowObjects(result) {
|
|
131
|
+
return result.rows.map(row => /** @type {T} */ (Object.fromEntries(result.columns.map((column, index) => [column.name, row[index] ?? null]))));
|
|
132
|
+
}
|
package/src/v1/sql.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { array, boolean, malformed, object, string } from "./decode.js";
|
|
2
|
-
import {
|
|
2
|
+
import { requireStoredId, requireName } from "./ids.js";
|
|
3
3
|
/**
|
|
4
4
|
* @import { JsonObject, JsonValue } from "./json.js"
|
|
5
5
|
* @import { V1RequestOptions } from "./terrabase-v1.js"
|
|
@@ -41,7 +41,7 @@ function decodeResult(value) {
|
|
|
41
41
|
const raw = object(value, "SQL result");
|
|
42
42
|
const rows = array(raw.rows, "SQL rows").map(entry => {
|
|
43
43
|
const row = object(entry, "SQL row");
|
|
44
|
-
return Object.freeze({ id:
|
|
44
|
+
return Object.freeze({ id: requireStoredId(row.id), value: object(row.value, "SQL row value") });
|
|
45
45
|
});
|
|
46
46
|
const matched = raw.matched;
|
|
47
47
|
if (typeof matched !== "number" || !Number.isInteger(matched) || matched < 0 || matched > 1000 || matched !== rows.length)
|
package/src/v1/terrabase-v1.js
CHANGED
|
@@ -4,13 +4,14 @@ import {
|
|
|
4
4
|
array, boolean, decodeDocument, decodeWrite, documentObject, durability, malformed, object, u64,
|
|
5
5
|
} from "./decode.js";
|
|
6
6
|
import { requireId } from "../ids.js";
|
|
7
|
-
import { isJsonPointer, requireFilter, requirePath } from "./filter.js";
|
|
7
|
+
import { searchCount, isJsonPointer, requireFilter, requireGeoPoint, requirePath } from "./filter.js";
|
|
8
8
|
import { requireDocumentId, requireName, requireVersion } from "./ids.js";
|
|
9
9
|
import { assertJsonObject, assertJsonValue, ExactDecimal, stringifyExactJson } from "./json.js";
|
|
10
10
|
import { readShapeManifest } from "./shape-manifest.js";
|
|
11
11
|
import { V1ShapeSubscription } from "./shape-subscription.js";
|
|
12
12
|
import { decryptSealedShapePage, readShapePage, readShapeKey } from "./shape-reader.js";
|
|
13
13
|
import { runSql, checkSql } from "./sql.js";
|
|
14
|
+
import { executeSql, sqlRowObjects, sqlStatement } from "./sql-execute.js";
|
|
14
15
|
import { streamChanges } from "./changes.js";
|
|
15
16
|
import { exportDocuments } from "./export.js";
|
|
16
17
|
import { V1Databases, getCollectionSchema, putCollectionSchema, deleteCollectionSchema, getCollectionTtl, putCollectionTtl, deleteCollectionTtl } from "./catalog.js";
|
|
@@ -22,6 +23,7 @@ import { V1ProjectUsageClient, V1DatabaseUsageClient } from "./usage.js";
|
|
|
22
23
|
import { V1Indexes } from "./indexes.js";
|
|
23
24
|
import { V1DatabaseSchemaClient } from "./database-schema.js";
|
|
24
25
|
import { V1SavedQueries } from "./saved-queries.js";
|
|
26
|
+
import { V1ChangeWebhooks } from "./change-webhooks.js";
|
|
25
27
|
/**
|
|
26
28
|
* @import { V1Document, V1Durability, V1WriteResult } from "./decode.js"
|
|
27
29
|
* @import { V1Filter, V1Path } from "./filter.js"
|
|
@@ -64,7 +66,32 @@ import { V1SavedQueries } from "./saved-queries.js";
|
|
|
64
66
|
* Vector similarity sort, nearest first. It must be the only sort key; limit is at most 100 and there is no cursor.
|
|
65
67
|
* @typedef {{ readonly near: { readonly path: V1Path; readonly vector: readonly number[]; readonly metric?: "cosine" | "l2" | undefined } }} V1NearSortKey
|
|
66
68
|
*/
|
|
67
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Geo distance sort: meters from a WGS 84 point to each document's `{lat, lon}` object at `path`, nearest first,
|
|
71
|
+
* returned as `distance`. Like the vector `near`, it is the only sort key, limit is at most 100 and there is no cursor.
|
|
72
|
+
* @typedef {{ readonly near: { readonly path: string; readonly lat: number; readonly lon: number } }} V1GeoNearSortKey
|
|
73
|
+
*/
|
|
74
|
+
/** Relevance order, only sort key with exactly one search predicate. @typedef {{ readonly path: "$score"; readonly order: "desc" }} V1ScoreSortKey */
|
|
75
|
+
/** @typedef {V1FieldSortKey | V1NearSortKey | V1GeoNearSortKey | V1ScoreSortKey} V1SortKey */
|
|
76
|
+
/**
|
|
77
|
+
* Groups matches instead of returning documents. Summaries consider numbers only; at most 16 summary paths in total.
|
|
78
|
+
* @typedef {object} V1Aggregate
|
|
79
|
+
* @property {readonly V1Path[] | undefined} [groupBy] 0-4 paths; without it every match is one group.
|
|
80
|
+
* @property {readonly string[] | undefined} [sum] Distinct JSON Pointers.
|
|
81
|
+
* @property {readonly string[] | undefined} [avg] Distinct JSON Pointers.
|
|
82
|
+
* @property {readonly string[] | undefined} [min] Distinct JSON Pointers.
|
|
83
|
+
* @property {readonly string[] | undefined} [max] Distinct JSON Pointers.
|
|
84
|
+
*/
|
|
85
|
+
/**
|
|
86
|
+
* One aggregate group. Summaries are keyed by the requested JSON Pointer; `sum` is 0 and the others null without numbers.
|
|
87
|
+
* @typedef {object} V1Group
|
|
88
|
+
* @property {readonly JsonValue[]} key One value per `groupBy` path (missing members group as null).
|
|
89
|
+
* @property {string} count Matching documents in the group (decimal u64).
|
|
90
|
+
* @property {Readonly<Record<string, number | null>> | undefined} [sum]
|
|
91
|
+
* @property {Readonly<Record<string, number | null>> | undefined} [avg]
|
|
92
|
+
* @property {Readonly<Record<string, number | null>> | undefined} [min]
|
|
93
|
+
* @property {Readonly<Record<string, number | null>> | undefined} [max]
|
|
94
|
+
*/
|
|
68
95
|
/**
|
|
69
96
|
* A query-v1 request. Every member is optional.
|
|
70
97
|
* @typedef {object} V1Query
|
|
@@ -74,6 +101,7 @@ import { V1SavedQueries } from "./saved-queries.js";
|
|
|
74
101
|
* @property {number | undefined} [limit] 1-1000 (server default 50).
|
|
75
102
|
* @property {string | null | undefined} [cursor] A previous page's `nextCursor`.
|
|
76
103
|
* @property {boolean | undefined} [count] Count every match (first page only).
|
|
104
|
+
* @property {V1Aggregate | undefined} [aggregate] Answer `groups` instead of items; `limit` bounds the groups. No sort, project, cursor or count.
|
|
77
105
|
*/
|
|
78
106
|
/** @typedef {{ readonly value: string; readonly exact: boolean }} V1Count */
|
|
79
107
|
/**
|
|
@@ -81,6 +109,7 @@ import { V1SavedQueries } from "./saved-queries.js";
|
|
|
81
109
|
* @property {readonly V1Document[]} items Documents in query order.
|
|
82
110
|
* @property {string | null} nextCursor
|
|
83
111
|
* @property {V1Count | undefined} count
|
|
112
|
+
* @property {readonly V1Group[] | undefined} groups An aggregate query's groups in key order (`items` is then empty).
|
|
84
113
|
*/
|
|
85
114
|
|
|
86
115
|
/** @typedef {{ readonly ifMatch: string } | { readonly ifNoneMatchAny: true }} V1Precondition */
|
|
@@ -97,7 +126,8 @@ import { V1SavedQueries } from "./saved-queries.js";
|
|
|
97
126
|
* @property {readonly V1TransactionOpResult[]} results One per op, in order.
|
|
98
127
|
*/
|
|
99
128
|
|
|
100
|
-
const queryMembers = new Set(["filter", "sort", "project", "limit", "cursor", "count"]);
|
|
129
|
+
const queryMembers = new Set(["filter", "sort", "project", "limit", "cursor", "count", "aggregate"]);
|
|
130
|
+
const summaries = /** @type {const} */ (["sum", "avg", "min", "max"]);
|
|
101
131
|
|
|
102
132
|
/**
|
|
103
133
|
* @param {string} path
|
|
@@ -181,16 +211,53 @@ function normalizePatch(patch) {
|
|
|
181
211
|
}
|
|
182
212
|
|
|
183
213
|
/**
|
|
184
|
-
* @param {
|
|
214
|
+
* @param {V1Aggregate} aggregate
|
|
215
|
+
* @param {V1Query} query
|
|
216
|
+
* @returns {Record<string, unknown>}
|
|
217
|
+
*/
|
|
218
|
+
function encodeAggregate(aggregate, query) {
|
|
219
|
+
if (typeof aggregate !== "object" || aggregate === null || Array.isArray(aggregate)) throw new TypeError("aggregate is an object.");
|
|
220
|
+
if (query.sort !== undefined || query.project !== undefined || query.cursor != null || query.count !== undefined)
|
|
221
|
+
throw new TypeError("aggregate takes no sort, project, cursor or count.");
|
|
222
|
+
for (const member of Object.keys(aggregate))
|
|
223
|
+
if (member !== "groupBy" && !summaries.includes(/** @type {any} */ (member))) throw new TypeError(`Unknown aggregate member ${member}.`);
|
|
224
|
+
/** @type {Record<string, unknown>} */
|
|
225
|
+
const body = {};
|
|
226
|
+
if (aggregate.groupBy !== undefined) {
|
|
227
|
+
if (!Array.isArray(aggregate.groupBy) || aggregate.groupBy.length > 4) throw new TypeError("groupBy has at most 4 paths.");
|
|
228
|
+
body.group_by = aggregate.groupBy.map(requirePath);
|
|
229
|
+
}
|
|
230
|
+
let total = 0;
|
|
231
|
+
for (const op of summaries) {
|
|
232
|
+
const paths = aggregate[op];
|
|
233
|
+
if (paths === undefined) continue;
|
|
234
|
+
if (!Array.isArray(paths) || new Set(paths).size !== paths.length) throw new TypeError(`${op} takes distinct JSON Pointers.`);
|
|
235
|
+
total += paths.length;
|
|
236
|
+
body[op] = paths.map(pointer);
|
|
237
|
+
}
|
|
238
|
+
if (total > 16) throw new TypeError("An aggregate has at most 16 sum, avg, min and max paths.");
|
|
239
|
+
return body;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {V1NearSortKey | V1GeoNearSortKey} key
|
|
185
244
|
* @param {V1Query} query
|
|
186
245
|
* @returns {Record<string, unknown>}
|
|
187
246
|
*/
|
|
188
247
|
function nearSort(key, query) {
|
|
189
248
|
if (Object.keys(key).length !== 1 || query.sort?.length !== 1) throw new TypeError("near is the only sort key and has no path or order beside it.");
|
|
249
|
+
if (searchCount(query.filter) !== 0) throw new TypeError("near cannot combine with search.");
|
|
190
250
|
if (query.cursor != null) throw new TypeError("A near query has one page and takes no cursor.");
|
|
191
251
|
if (query.limit !== undefined && query.limit > 100) throw new TypeError("A near query limit is at most 100.");
|
|
192
252
|
const near = key.near;
|
|
193
253
|
if (near === null || typeof near !== "object") throw new TypeError("near is an object.");
|
|
254
|
+
if ("lat" in near || "lon" in near) {
|
|
255
|
+
const geo = /** @type {V1GeoNearSortKey["near"]} */ (near);
|
|
256
|
+
for (const member of Object.keys(geo)) if (!["path", "lat", "lon"].includes(member)) throw new TypeError(`A geo near takes path, lat and lon, not ${member}.`);
|
|
257
|
+
requireGeoPoint(geo.lat, geo.lon);
|
|
258
|
+
if (requirePath(geo.path) === "$id") throw new TypeError("A geo near path is a JSON Pointer.");
|
|
259
|
+
return { near: { path: geo.path, lat: geo.lat, lon: geo.lon } };
|
|
260
|
+
}
|
|
194
261
|
for (const member of Object.keys(near)) if (!["path", "vector", "metric"].includes(member)) throw new TypeError(`Unknown near member ${member}.`);
|
|
195
262
|
if (near.metric !== undefined && near.metric !== "cosine" && near.metric !== "l2") throw new TypeError('near metric is "cosine" or "l2".');
|
|
196
263
|
const vector = near.vector;
|
|
@@ -218,6 +285,10 @@ function encodeQuery(query) {
|
|
|
218
285
|
if ("near" in key) return nearSort(key, query);
|
|
219
286
|
for (const member of Object.keys(key)) if (member !== "path" && member !== "order") throw new TypeError(`Unknown sort member ${member}.`);
|
|
220
287
|
if (key.order !== undefined && key.order !== "asc" && key.order !== "desc") throw new TypeError('Sort order is "asc" or "desc".');
|
|
288
|
+
if (key.path === "$score") {
|
|
289
|
+
if (key.order !== "desc" || query.sort?.length !== 1 || searchCount(query.filter) !== 1) throw new TypeError("$score requires descending order as the only sort key and exactly one search predicate.");
|
|
290
|
+
return { path: "$score", order: "desc" };
|
|
291
|
+
}
|
|
221
292
|
return { path: requirePath(key.path), ...(key.order === undefined ? {} : { order: key.order }) };
|
|
222
293
|
});
|
|
223
294
|
}
|
|
@@ -237,9 +308,29 @@ function encodeQuery(query) {
|
|
|
237
308
|
if (typeof query.count !== "boolean") throw new TypeError("count is a boolean.");
|
|
238
309
|
body.count = query.count;
|
|
239
310
|
}
|
|
311
|
+
if (query.aggregate !== undefined) body.aggregate = encodeAggregate(query.aggregate, query);
|
|
240
312
|
return stringifyExactJson(body, "query");
|
|
241
313
|
}
|
|
242
314
|
|
|
315
|
+
/**
|
|
316
|
+
* @param {JsonValue} value
|
|
317
|
+
* @returns {V1Group}
|
|
318
|
+
*/
|
|
319
|
+
function decodeGroup(value) {
|
|
320
|
+
const raw = object(value, "aggregate group");
|
|
321
|
+
/** @type {Record<string, unknown>} */
|
|
322
|
+
const group = { key: Object.freeze([...array(raw.key, "group key")]), count: u64(raw.count, "group count") };
|
|
323
|
+
for (const op of summaries) {
|
|
324
|
+
if (raw[op] === undefined) continue;
|
|
325
|
+
group[op] = Object.freeze(Object.fromEntries(Object.entries(object(raw[op], `group ${op}`)).map(([path, summary]) => {
|
|
326
|
+
const number = summary instanceof ExactDecimal ? summary.toNumber() : summary;
|
|
327
|
+
if (number !== null && (typeof number !== "number" || !Number.isFinite(number))) malformed(`group ${op}`);
|
|
328
|
+
return [path, number];
|
|
329
|
+
})));
|
|
330
|
+
}
|
|
331
|
+
return /** @type {V1Group} */ (Object.freeze(group));
|
|
332
|
+
}
|
|
333
|
+
|
|
243
334
|
/**
|
|
244
335
|
* @param {JsonValue | undefined} value
|
|
245
336
|
* @returns {V1QueryPage}
|
|
@@ -256,14 +347,19 @@ export function decodeQueryPage(value) {
|
|
|
256
347
|
}
|
|
257
348
|
return Object.freeze({
|
|
258
349
|
items: Object.freeze(array(raw.items, "query items").map(item => {
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
350
|
+
const raw = object(item, "document");
|
|
351
|
+
const document = { ...decodeDocument(item) };
|
|
352
|
+
for (const field of /** @type {const} */ (["distance", "score"])) {
|
|
353
|
+
if (raw[field] === undefined) continue;
|
|
354
|
+
const value = raw[field] instanceof ExactDecimal ? raw[field].toNumber() : raw[field];
|
|
355
|
+
if (typeof value !== "number" || !Number.isFinite(value)) malformed(field);
|
|
356
|
+
document[field] = value;
|
|
357
|
+
}
|
|
358
|
+
return Object.freeze(document);
|
|
264
359
|
})),
|
|
265
360
|
nextCursor: typeof next === "string" && next !== "" ? next : null,
|
|
266
361
|
count,
|
|
362
|
+
groups: raw.groups === undefined ? undefined : Object.freeze(array(raw.groups, "aggregate groups").map(decodeGroup)),
|
|
267
363
|
});
|
|
268
364
|
}
|
|
269
365
|
|
|
@@ -301,6 +397,8 @@ function encodeDocument(document) {
|
|
|
301
397
|
|
|
302
398
|
/** A collection handle by name. It performs no I/O until an operation runs. */
|
|
303
399
|
export class V1Collection {
|
|
400
|
+
/** @type {V1ChangeWebhooks} */
|
|
401
|
+
webhooks;
|
|
304
402
|
/** @type {V1Transport} */
|
|
305
403
|
#transport;
|
|
306
404
|
/** @type {Readonly<Record<string, string>>} */
|
|
@@ -319,6 +417,7 @@ export class V1Collection {
|
|
|
319
417
|
this.#transport = transport;
|
|
320
418
|
this.database = requireName("database", database);
|
|
321
419
|
this.name = requireName("collection", name);
|
|
420
|
+
this.webhooks = new V1ChangeWebhooks(transport, this.database, this.name);
|
|
322
421
|
this.#params = Object.freeze({ database: this.database, collection: this.name });
|
|
323
422
|
Object.freeze(this);
|
|
324
423
|
}
|
|
@@ -852,6 +951,39 @@ export class V1Database {
|
|
|
852
951
|
return checkSql(this.#transport, this.name, statement, options, "explainSql");
|
|
853
952
|
}
|
|
854
953
|
|
|
954
|
+
/**
|
|
955
|
+
* Runs one relational SQL statement as its own transaction. Reads need documents:read; writes and DDL need documents:write.
|
|
956
|
+
* @param {string} sql
|
|
957
|
+
* @param {import("./sql-execute.js").V1SqlArguments} [args] Positional array, or named values keyed without their prefix.
|
|
958
|
+
* @param {V1RequestOptions} [options]
|
|
959
|
+
* @returns {Promise<import("./sql-execute.js").V1SqlExecuteResult>}
|
|
960
|
+
*/
|
|
961
|
+
async execute(sql, args, options = {}) {
|
|
962
|
+
const [result] = await executeSql(this.#transport, this.name, [sqlStatement(sql, args)], options);
|
|
963
|
+
return /** @type {import("./sql-execute.js").V1SqlExecuteResult} */ (result);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Runs one statement and returns its rows as objects keyed by column name.
|
|
968
|
+
* @template [T=Record<string, import("./sql-execute.js").V1SqlValue>]
|
|
969
|
+
* @param {string} sql
|
|
970
|
+
* @param {import("./sql-execute.js").V1SqlArguments} [args]
|
|
971
|
+
* @param {V1RequestOptions} [options]
|
|
972
|
+
* @returns {Promise<T[]>}
|
|
973
|
+
*/
|
|
974
|
+
async query(sql, args, options = {}) {
|
|
975
|
+
return sqlRowObjects(await this.execute(sql, args, options));
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Runs 1 to 256 statements as one transaction: all commit or none do. A refused statement throws SqlError.
|
|
980
|
+
* @param {readonly (string | import("./sql-execute.js").V1SqlStatement)[]} statements
|
|
981
|
+
* @param {V1RequestOptions} [options]
|
|
982
|
+
*/
|
|
983
|
+
batch(statements, options = {}) {
|
|
984
|
+
return executeSql(this.#transport, this.name, statements, options);
|
|
985
|
+
}
|
|
986
|
+
|
|
855
987
|
/** @param {import("./changes.js").V1ChangeStreamRequest} [request] @param {V1RequestOptions} [options] */
|
|
856
988
|
changes(request = {}, options = {}) {
|
|
857
989
|
return streamChanges(this.#transport, this.name, request, options.signal);
|
package/src/v1/transport.js
CHANGED
|
@@ -44,6 +44,7 @@ const tokenPattern = /^(?!tbk1\.)(?:[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-
|
|
|
44
44
|
* @property {URLSearchParams | undefined} [query]
|
|
45
45
|
* @property {AbortSignal | undefined} [signal]
|
|
46
46
|
* @property {"no-store" | undefined} [cache] Authenticated shape epoch keys must bypass caches.
|
|
47
|
+
* @property {"follow" | undefined} [redirect] Only the shape bootstrap, after a browser hides its 307 behind an opaque redirect.
|
|
47
48
|
*/
|
|
48
49
|
|
|
49
50
|
/**
|
|
@@ -51,6 +52,7 @@ const tokenPattern = /^(?!tbk1\.)(?:[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-
|
|
|
51
52
|
* @property {number} status
|
|
52
53
|
* @property {Headers} headers
|
|
53
54
|
* @property {JsonValue | undefined} value
|
|
55
|
+
* @property {string | undefined} [redirectedTo] The final URL after a followed redirect.
|
|
54
56
|
*/
|
|
55
57
|
|
|
56
58
|
/**
|
|
@@ -134,6 +136,23 @@ export class BodyTransportError extends Error {
|
|
|
134
136
|
}
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
/** A request that failed in transit before any response; the native failure is the cause. */
|
|
140
|
+
export class RequestTransportError extends TypeError {
|
|
141
|
+
/** @param {unknown} cause */
|
|
142
|
+
constructor(cause) {
|
|
143
|
+
super(cause instanceof Error ? cause.message : "TerraBase request failed in transit.", { cause });
|
|
144
|
+
this.name = "RequestTransportError";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A browser hid a 307 behind an opaque redirect; only the shape bootstrap may follow it. */
|
|
149
|
+
export class OpaqueRedirectError extends TypeError {
|
|
150
|
+
constructor() {
|
|
151
|
+
super("Fresh shape bootstrap requires server fetch; obtain a handle and snapshot offset with collection.shape() on your server.");
|
|
152
|
+
this.name = "OpaqueRedirectError";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
137
156
|
/** Internal transport bound to one project credential. */
|
|
138
157
|
export class V1Transport {
|
|
139
158
|
/** @type {string | undefined} */
|
|
@@ -353,7 +372,8 @@ export class V1Transport {
|
|
|
353
372
|
await response.body?.cancel();
|
|
354
373
|
return { status: response.status, headers: response.headers, value: undefined };
|
|
355
374
|
}
|
|
356
|
-
return { status: response.status, headers: response.headers, value: await this.readJson(response, options.signal)
|
|
375
|
+
return { status: response.status, headers: response.headers, value: await this.readJson(response, options.signal),
|
|
376
|
+
...(response.redirected ? { redirectedTo: response.url } : {}) };
|
|
357
377
|
}
|
|
358
378
|
|
|
359
379
|
/**
|
|
@@ -387,7 +407,7 @@ export class V1Transport {
|
|
|
387
407
|
if (route.responseMediaType !== "") headers.set("accept", route.responseMediaType);
|
|
388
408
|
if (options.body !== undefined) headers.set("content-type", options.contentType ?? "application/json");
|
|
389
409
|
/** @type {RequestInit & { duplex?: "half" }} */
|
|
390
|
-
const init = { method: route.method, headers, redirect: "manual" };
|
|
410
|
+
const init = { method: route.method, headers, redirect: options.redirect ?? "manual" };
|
|
391
411
|
if (options.cache !== undefined) init.cache = options.cache;
|
|
392
412
|
if (route.permission.endsWith(":write") && typeof process !== "undefined" && process.release?.name === "node") {
|
|
393
413
|
// Node fetch replays buffered or absent bodies on 421; use a one-shot stream, including DELETE.
|
|
@@ -397,13 +417,15 @@ export class V1Transport {
|
|
|
397
417
|
} else if (options.body !== undefined) init.body = typeof options.body === "string" ? options.body : options.body.slice().buffer;
|
|
398
418
|
if (options.signal !== undefined) init.signal = options.signal;
|
|
399
419
|
options.signal?.throwIfAborted();
|
|
400
|
-
|
|
420
|
+
/** @type {Response} */
|
|
421
|
+
let response;
|
|
422
|
+
try { response = await this.#fetch(url, init); }
|
|
423
|
+
catch (error) { options.signal?.throwIfAborted(); throw new RequestTransportError(error); }
|
|
401
424
|
if (options.signal?.aborted) {
|
|
402
425
|
void response.body?.cancel().catch(() => undefined);
|
|
403
426
|
options.signal.throwIfAborted();
|
|
404
427
|
}
|
|
405
|
-
if (response.type === "opaqueredirect")
|
|
406
|
-
throw new TypeError("Fresh shape bootstrap requires server fetch; obtain a handle and snapshot offset with collection.shape() on your server.");
|
|
428
|
+
if (response.type === "opaqueredirect") throw new OpaqueRedirectError();
|
|
407
429
|
if (!route.successStatuses.includes(response.status)) throw await this.error(response, options.signal);
|
|
408
430
|
return response;
|
|
409
431
|
}
|