terrascale 0.3.0 → 1.0.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.
@@ -0,0 +1,411 @@
1
+ // Deprecated: internal transport for the retired SQLite emulator only.
2
+ import {
3
+ decodeDatabaseCommitReceipt, decodeDatabasePoint, decodeDatabaseQueryPage,
4
+ decodeDatabaseSubscription, decodeDatabaseStatus, encodeDatabaseStatus, encodeDatabaseCommit, encodeDatabaseQuery,
5
+ encodeDatabaseSubscription, encodeDatabaseAuthority, parseDatabaseJson, requireDatabaseU64, requireDatabaseUuid,
6
+ } from "../database-codec.js";
7
+ /**
8
+ * @import {
9
+ * DatabaseAuthority, DatabaseCommit, DatabaseCommitReceipt, DatabasePoint,
10
+ * DatabaseQuery, DatabaseQueryPage, DatabaseResult, DatabaseScope,
11
+ * DatabaseSubscriptionRequest, DatabaseSubscriptionResponse, DatabaseTransactionIdentity,
12
+ * DatabaseTransactionStatus,
13
+ * } from "../database-types.js"
14
+ * @import { FetchLike } from "../config.js"
15
+ */
16
+
17
+ const maximumResponseBytes = 4 * 1024 * 1024;
18
+
19
+ /**
20
+ * Internal options for the deprecated local emulator. Remote origins are refused.
21
+ * @typedef {DatabaseScope & {
22
+ * readonly apiKey: string | (() => string | Promise<string>);
23
+ * readonly endpoint?: string;
24
+ * readonly discoveryTimeoutMs?: number;
25
+ * readonly requestTimeoutMs?: number;
26
+ * readonly fetch?: FetchLike;
27
+ * }} TerraBaseClientConfig
28
+ */
29
+
30
+ /** @typedef {{ readonly signal?: AbortSignal }} DatabaseRequestOptions */
31
+
32
+ // Type-level brand only; the symbol is never attached to a value.
33
+ const preparedBrand = Symbol("preparedBrand");
34
+ /** @typedef {DatabaseTransactionIdentity & { readonly [preparedBrand]: true }} PreparedDatabaseCommit */
35
+ /** @type {WeakMap<PreparedDatabaseCommit, { readonly client: TerraBaseClient; readonly scope: string; readonly endpoint: string | undefined; readonly body: string }>} */
36
+ const preparedRequests = new WeakMap();
37
+
38
+ /**
39
+ * @param {number | undefined} value
40
+ * @param {number} defaultValue
41
+ * @param {number} maximum
42
+ * @returns {number}
43
+ */
44
+ function timeout(value, defaultValue, maximum) {
45
+ const resolved = value ?? defaultValue;
46
+ if (!Number.isInteger(resolved) || resolved < 1 || resolved > maximum) throw new TypeError("Timeout is outside its bounds.");
47
+ return resolved;
48
+ }
49
+
50
+ /**
51
+ * @param {DatabaseAuthority} authority
52
+ * @returns {DatabaseAuthority}
53
+ */
54
+ function identityAuthority(authority) {
55
+ return authority.mode === "leaderless" ? Object.freeze({ ...authority })
56
+ : Object.freeze({ ...authority, writer: Object.freeze({ ...authority.writer }) });
57
+ }
58
+
59
+ /**
60
+ * @param {DatabaseScope} scope
61
+ * @returns {string}
62
+ */
63
+ function scopePath(scope) {
64
+ return `/v1/tenants/${requireDatabaseUuid(scope.tenant, "tenant")}/databases/${requireDatabaseUuid(scope.database, "database")}/shards/${requireDatabaseU64(scope.shard, "shard")}`;
65
+ }
66
+
67
+ /**
68
+ * @param {unknown} raw
69
+ * @returns {string}
70
+ */
71
+ function routeError(raw) {
72
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw) || Object.keys(raw).some(key => key !== "error")) return "invalid_response";
73
+ const error = /** @type {Record<string, unknown>} */ (raw).error;
74
+ if (typeof error !== "object" || error === null || Array.isArray(error)) return "invalid_response";
75
+ const value = /** @type {Record<string, unknown>} */ (error);
76
+ if (Object.keys(value).some(key => key !== "code")) return "invalid_response";
77
+ return typeof value.code === "string" && /^[a-z][a-z0-9_]{0,63}$/u.test(value.code) ? value.code : "invalid_response";
78
+ }
79
+
80
+ /**
81
+ * @param {Response} response
82
+ * @param {AbortSignal} signal
83
+ * @returns {Promise<unknown>}
84
+ */
85
+ async function responseJson(response, signal) {
86
+ const mediaType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
87
+ if (mediaType !== "application/json") {
88
+ void response.body?.cancel().catch(() => undefined);
89
+ throw new TypeError("Expected application/json.");
90
+ }
91
+ const reader = response.body?.getReader();
92
+ if (reader === undefined) throw new TypeError("Response has no JSON body.");
93
+ /** @type {Uint8Array[]} */
94
+ const parts = [];
95
+ let size = 0;
96
+ try {
97
+ while (true) {
98
+ /** @type {ReadableStreamReadResult<Uint8Array>} */
99
+ let part;
100
+ try { part = await withCancellation(reader.read(), signal); }
101
+ catch (error) {
102
+ if (signal.aborted) throw error;
103
+ throw new Error("Native response transport failed.", { cause: error });
104
+ }
105
+ if (part.done) break;
106
+ size += part.value.length;
107
+ if (size > maximumResponseBytes) throw new TypeError("Response exceeds its byte limit.");
108
+ parts.push(part.value);
109
+ }
110
+ } catch (error) {
111
+ void reader.cancel().catch(() => undefined);
112
+ throw error;
113
+ } finally { reader.releaseLock(); }
114
+ const bytes = new Uint8Array(size);
115
+ let offset = 0;
116
+ for (const part of parts) { bytes.set(part, offset); offset += part.length; }
117
+ return parseDatabaseJson(bytes);
118
+ }
119
+
120
+ /**
121
+ * @template T
122
+ * @param {Promise<T>} promise
123
+ * @param {AbortSignal} signal
124
+ * @param {(value: T) => void} [disposeLateValue]
125
+ * @returns {Promise<T>}
126
+ */
127
+ function withCancellation(promise, signal, disposeLateValue) {
128
+ return new Promise((resolve, reject) => {
129
+ /** @returns {void} */
130
+ const cleanup = () => signal.removeEventListener("abort", abort);
131
+ /** @returns {void} */
132
+ const abort = () => {
133
+ cleanup();
134
+ reject(signal.reason);
135
+ };
136
+ if (signal.aborted) abort();
137
+ else signal.addEventListener("abort", abort, { once: true });
138
+ // Observe the started operation even if it aborted synchronously before
139
+ // this wrapper ran; a late fetch result still owns a response body.
140
+ void promise.then(
141
+ value => {
142
+ cleanup();
143
+ if (signal.aborted) {
144
+ try {
145
+ disposeLateValue?.(value);
146
+ } catch {
147
+ // Cleanup cannot replace the terminal cancellation.
148
+ }
149
+ reject(signal.reason);
150
+ } else resolve(value);
151
+ },
152
+ error => {
153
+ cleanup();
154
+ reject(signal.aborted ? signal.reason : error);
155
+ },
156
+ );
157
+ });
158
+ }
159
+
160
+ /** Current scoped document client. All mutations are server commits in one declared shard. */
161
+ export class TerraBaseClient {
162
+ /**
163
+ * @readonly
164
+ * @type {DatabaseScope}
165
+ */
166
+ #scope;
167
+ /**
168
+ * @readonly
169
+ * @type {TerraBaseClientConfig}
170
+ */
171
+ #config;
172
+ /**
173
+ * @readonly
174
+ * @type {FetchLike}
175
+ */
176
+ #fetch;
177
+ /**
178
+ * @readonly
179
+ * @type {string}
180
+ */
181
+ #path;
182
+ /**
183
+ * @readonly
184
+ * @type {string | undefined}
185
+ */
186
+ #endpoint;
187
+ /**
188
+ * @readonly
189
+ * @type {number}
190
+ */
191
+ #requestTimeoutMs;
192
+
193
+ /** @returns {DatabaseScope} */
194
+ get scope() { return this.#scope; }
195
+
196
+ /** @param {TerraBaseClientConfig} config */
197
+ constructor(config) {
198
+ this.#scope = Object.freeze({ tenant: requireDatabaseUuid(config.tenant, "tenant"), database: requireDatabaseUuid(config.database, "database"), shard: requireDatabaseU64(config.shard, "shard") });
199
+ this.#config = { ...config };
200
+ this.#fetch = config.fetch ?? globalThis.fetch.bind(globalThis);
201
+ this.#path = scopePath(this.#scope);
202
+ const endpoint = new URL(config.endpoint ?? "http://127.0.0.1");
203
+ if (endpoint.protocol !== "http:" || !["127.0.0.1", "localhost", "[::1]"].includes(endpoint.hostname) || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || endpoint.pathname !== "/")
204
+ throw new TypeError("The retired SQLite client only supports a local emulator origin.");
205
+ this.#endpoint = endpoint.origin;
206
+ this.#requestTimeoutMs = timeout(config.requestTimeoutMs, 30_000, 300_000);
207
+ if (typeof config.apiKey !== "function") this.#validateKey(config.apiKey);
208
+ }
209
+
210
+ /**
211
+ * @param {string} collection
212
+ * @param {string} document
213
+ * @param {DatabaseRequestOptions} [options]
214
+ * @returns {Promise<DatabaseResult<DatabasePoint>>}
215
+ */
216
+ read(collection, document, options = {}) {
217
+ const path = `${this.#collectionPath(collection)}/documents/${requireDatabaseUuid(document, "document")}`;
218
+ return this.#request(path, "GET", undefined, raw => this.#scopedPoint(decodeDatabasePoint(raw)), options);
219
+ }
220
+
221
+ /**
222
+ * @param {string} collection
223
+ * @param {DatabaseQuery} query
224
+ * @param {DatabaseRequestOptions} [options]
225
+ * @returns {Promise<DatabaseResult<DatabaseQueryPage>>}
226
+ */
227
+ query(collection, query, options = {}) {
228
+ const limit = query.limit;
229
+ const body = encodeDatabaseQuery(query);
230
+ return this.#request(`${this.#collectionPath(collection)}/documents/query`, "POST", body, raw => {
231
+ const page = decodeDatabaseQueryPage(raw);
232
+ if (page.rows.length > limit) throw new TypeError("Query page exceeds the requested limit.");
233
+ page.rows.forEach(row => this.#scopedPoint(row.point));
234
+ return page;
235
+ }, options);
236
+ }
237
+
238
+ /**
239
+ * @param {string} collection
240
+ * @param {DatabaseSubscriptionRequest} request
241
+ * @param {DatabaseRequestOptions} [options]
242
+ * @returns {Promise<DatabaseResult<DatabaseSubscriptionResponse>>}
243
+ */
244
+ subscription(collection, request, options = {}) {
245
+ const phase = request.phase;
246
+ const limit = request.phase === "snapshot" ? request.limit : undefined;
247
+ const body = encodeDatabaseSubscription(request);
248
+ return this.#request(`${this.#collectionPath(collection)}/documents/subscriptions`, "POST", body, raw => {
249
+ const result = decodeDatabaseSubscription(raw);
250
+ if (result.phase !== phase) throw new TypeError("Subscription response belongs to another phase.");
251
+ if (result.phase === "snapshot" && limit !== undefined && result.rows.length > limit) throw new TypeError("Snapshot page exceeds the requested limit.");
252
+ if (result.phase === "snapshot") result.rows.forEach(row => this.#scopedPoint(row.point));
253
+ else result.groups.forEach(group => group.effects.forEach(effect => { if (effect.point !== null) this.#scopedPoint(effect.point); }));
254
+ return result;
255
+ }, options);
256
+ }
257
+
258
+ /**
259
+ * Prepare once and retain this identity for exact retry and durable status resolution.
260
+ * @param {DatabaseCommit} commit
261
+ * @returns {Promise<PreparedDatabaseCommit>}
262
+ */
263
+ async prepareCommit(commit) {
264
+ const transaction_id = Object.freeze({ ...commit.transaction_id });
265
+ const authority = identityAuthority(commit.authority);
266
+ const encoded = await encodeDatabaseCommit(this.#scope, commit);
267
+ const prepared = /** @type {PreparedDatabaseCommit} */ (Object.freeze({ transaction_id, authority, effect_digest: encoded.effect_digest }));
268
+ preparedRequests.set(prepared, { client: this, scope: this.#path, endpoint: this.#endpoint, body: encoded.body });
269
+ return prepared;
270
+ }
271
+
272
+ /**
273
+ * @param {PreparedDatabaseCommit} prepared
274
+ * @param {DatabaseRequestOptions} [options]
275
+ * @returns {Promise<DatabaseResult<DatabaseCommitReceipt>>}
276
+ */
277
+ commit(prepared, options = {}) {
278
+ const request = preparedRequests.get(prepared);
279
+ if (request === undefined || request.client !== this || request.scope !== this.#path) throw new TypeError("Prepared commit belongs to another client or scope, or was not prepared by this SDK.");
280
+ if (request.endpoint !== this.#endpoint) throw new TypeError("Prepared commit belongs to another endpoint.");
281
+ return this.#request(`${this.#path}/transactions`, "POST", request.body, raw => {
282
+ const receipt = decodeDatabaseCommitReceipt(raw);
283
+ if (receipt.event.database !== this.#scope.database || receipt.event.shard !== this.#scope.shard ||
284
+ receipt.effect_digest !== prepared.effect_digest || JSON.stringify(encodeDatabaseAuthority(receipt.authority)) !== JSON.stringify(encodeDatabaseAuthority(prepared.authority)) ||
285
+ receipt.transaction_id.nonce !== prepared.transaction_id.nonce || receipt.transaction_id.expires_at_unix_ms !== prepared.transaction_id.expires_at_unix_ms) {
286
+ throw new TypeError("Receipt belongs to another operation or database scope.");
287
+ }
288
+ return receipt;
289
+ }, options, prepared);
290
+ }
291
+
292
+ /**
293
+ * Unknown, pending and expired outcomes remain unresolved; an absent document proves no abort.
294
+ * @param {DatabaseTransactionIdentity} identity
295
+ * @param {DatabaseRequestOptions} [options]
296
+ * @returns {Promise<DatabaseResult<DatabaseTransactionStatus>>}
297
+ */
298
+ transactionStatus(identity, options = {}) {
299
+ const snapshot = Object.freeze({ ...identity, transaction_id: Object.freeze({ ...identity.transaction_id }), authority: identityAuthority(identity.authority) });
300
+ const body = encodeDatabaseStatus(snapshot);
301
+ return this.#request(`${this.#path}/transactions/status`, "POST", body, raw => {
302
+ const status = decodeDatabaseStatus(raw);
303
+ if (status.effect_digest !== snapshot.effect_digest || status.transaction_id.nonce !== snapshot.transaction_id.nonce || status.transaction_id.expires_at_unix_ms !== snapshot.transaction_id.expires_at_unix_ms || JSON.stringify(encodeDatabaseAuthority(status.authority)) !== JSON.stringify(encodeDatabaseAuthority(snapshot.authority))) {
304
+ throw new TypeError("Status belongs to another operation.");
305
+ }
306
+ if (status.state === "committed" && (status.outcome.receipt.event.database !== this.#scope.database || status.outcome.receipt.event.shard !== this.#scope.shard)) throw new TypeError("Status receipt belongs to another database scope.");
307
+ return status;
308
+ }, options);
309
+ }
310
+
311
+ /**
312
+ * @param {string} collection
313
+ * @returns {string}
314
+ */
315
+ #collectionPath(collection) { return `${this.#path}/collections/${requireDatabaseUuid(collection, "collection")}`; }
316
+
317
+ /**
318
+ * @param {DatabasePoint} point
319
+ * @returns {DatabasePoint}
320
+ */
321
+ #scopedPoint(point) {
322
+ if (point.siblings.some(sibling => sibling.event.database !== this.#scope.database || sibling.event.shard !== this.#scope.shard)) {
323
+ throw new TypeError("Point belongs to another database scope.");
324
+ }
325
+ return point;
326
+ }
327
+
328
+ /**
329
+ * @param {unknown} key
330
+ * @returns {string}
331
+ */
332
+ #validateKey(key) {
333
+ if (typeof key !== "string" || key.length === 0 || key.length > 4096 || /[\s\p{Cc}]/u.test(key)) throw new TypeError("Native API key must be nonblank and bounded.");
334
+ return key;
335
+ }
336
+
337
+ /**
338
+ * @template T
339
+ * @param {string} path
340
+ * @param {"GET" | "POST"} method
341
+ * @param {string | undefined} body
342
+ * @param {(raw: unknown) => T} decode
343
+ * @param {DatabaseRequestOptions} options
344
+ * @param {DatabaseTransactionIdentity} [transaction]
345
+ * @returns {Promise<DatabaseResult<T>>}
346
+ */
347
+ async #request(path, method, body, decode, options, transaction) {
348
+ let sent = false;
349
+ /** @type {number | undefined} */
350
+ let responseStatus;
351
+ const signal = AbortSignal.any([AbortSignal.timeout(this.#requestTimeoutMs), ...(options.signal === undefined ? [] : [options.signal])]);
352
+ try {
353
+ signal.throwIfAborted();
354
+ const apiKey = this.#validateKey(typeof this.#config.apiKey === "function" ? await withCancellation(Promise.resolve(this.#config.apiKey()), signal) : this.#config.apiKey);
355
+ const origin = /** @type {string} */ (this.#endpoint);
356
+ signal.throwIfAborted();
357
+ const headers = new Headers({ accept: "application/json", authorization: `Bearer ${apiKey}` });
358
+ if (body !== undefined) headers.set("content-type", "application/json");
359
+ const request = new Request(new URL(path, origin), { method, headers, credentials: "omit", redirect: "manual", cache: "no-store", referrerPolicy: "no-referrer", signal, ...(body === undefined ? {} : { body }) });
360
+ sent = true;
361
+ /** @type {Response} */
362
+ let response;
363
+ try {
364
+ response = await withCancellation(this.#fetch(request), signal, late => {
365
+ void late.body?.cancel().catch(() => undefined);
366
+ });
367
+ } catch (error) {
368
+ if (signal.aborted) throw error;
369
+ throw new Error("Native request transport failed.", { cause: error });
370
+ }
371
+ responseStatus = response.status;
372
+ if (signal.aborted) {
373
+ void response.body?.cancel().catch(() => undefined);
374
+ signal.throwIfAborted();
375
+ }
376
+ if (response.redirected) {
377
+ void response.body?.cancel().catch(() => undefined);
378
+ throw new TypeError("Native redirects are not admitted.");
379
+ }
380
+ if (response.ok && response.status !== 200) {
381
+ void response.body?.cancel().catch(() => undefined);
382
+ throw new TypeError("The native protocol admits only HTTP 200 success.");
383
+ }
384
+ const raw = await responseJson(response, signal);
385
+ signal.throwIfAborted();
386
+ if (!response.ok) {
387
+ const code = routeError(raw);
388
+ /** @type {"uncertain" | "rejected" | "unknown"} */
389
+ let mutationOutcome = "unknown";
390
+ if (code === "commit_uncertain") mutationOutcome = "uncertain";
391
+ else if (code === "precondition_failed" && response.status === 412) mutationOutcome = "rejected";
392
+ // Authentication, cancellation and changed-effect errors do not resolve an earlier attempt.
393
+ // Only the producer's retained precondition rejection is a terminal mutation outcome.
394
+ return { ok: false, error: { code, status: response.status, ...(transaction === undefined ? {} : { transaction, mutation_outcome: mutationOutcome }) } };
395
+ }
396
+ return { ok: true, status: response.status, value: decode(raw) };
397
+ } catch (error) {
398
+ let code = "transport_unavailable";
399
+ if (signal.aborted) code = "cancelled";
400
+ else if (error instanceof TypeError || error instanceof SyntaxError) code = "invalid_response";
401
+ const status = responseStatus;
402
+ return { ok: false, error: { code, ...(status === undefined ? {} : { status }), ...(transaction === undefined ? {} : { transaction, mutation_outcome: sent ? "uncertain" : "unknown" }) } };
403
+ }
404
+ }
405
+ }
406
+
407
+ /**
408
+ * @param {TerraBaseClientConfig} config
409
+ * @returns {TerraBaseClient}
410
+ */
411
+ export function createTerraBaseClient(config) { return new TerraBaseClient(config); }
package/src/postgres.js CHANGED
@@ -1,4 +1,5 @@
1
- /** Node-only PostgreSQL client for the signed INT8/TEXT point SQL listener. */
1
+ /** @deprecated The C# TerraBase has no PostgreSQL listener.
2
+ * Node-only PostgreSQL client for the signed INT8/TEXT point SQL listener. */
2
3
  import { lookup } from "node:dns/promises";
3
4
  import { isIP } from "node:net";
4
5
  import { X509Certificate } from "node:crypto";
@@ -229,6 +230,7 @@ function columns(fields) {
229
230
  }
230
231
 
231
232
  /** One driver connection; no automatic reconnect, mutation retry or callback replay. */
233
+ /** @deprecated The C# TerraBase has no PostgreSQL listener. */
232
234
  export class PostgresClient {
233
235
  /** @type {DriverClient} */
234
236
  #driver;
@@ -1,6 +1,6 @@
1
1
  import { createDatabaseQuery, DatabaseSubscriptionError } from "../database-view.js";
2
2
  /** @import { DatabaseQueryOptions, DatabaseViewPosition, DatabaseViewSnapshot } from "../database-view.js" */
3
- /** @import { PreparedDatabaseCommit, TerraBaseClient } from "../database.js" */
3
+ /** @import { PreparedDatabaseCommit, TerraBaseClient } from "../local/legacy-client.js" */
4
4
  /** @import { DatabaseCommitReceipt, DatabaseQueryRow, DatabaseResult, DatabaseScope } from "../database-types.js" */
5
5
 
6
6
  /**
@@ -498,6 +498,7 @@ export class TerraScaleTanStackCollectionSync {
498
498
  }
499
499
 
500
500
  /**
501
+ * @deprecated Retired SQLite/Rust adapter; no C# shape adapter is provided.
501
502
  * Options for `createCollection(terrascaleCollectionOptions(...))`.
502
503
  *
503
504
  * @template {object} T
@@ -0,0 +1,110 @@
1
+ // Typed decoding of TerraBase v1 response bodies.
2
+ import { parseU64, requireVersion } from "./ids.js";
3
+ import { isJsonObject } from "./json.js";
4
+ /** @import { JsonObject, JsonValue } from "./json.js" */
5
+
6
+ /** @typedef {"local_durable"} V1Durability */
7
+ /**
8
+ * C# single-writer documents always carry a value, never conflict siblings.
9
+ * @typedef {{ readonly id: string; readonly version: string; readonly state: "value"; readonly document: JsonObject }} V1Document
10
+ */
11
+
12
+ /**
13
+ * The result of a document write.
14
+ * @typedef {object} V1WriteResult
15
+ * @property {string} id
16
+ * @property {string} version For a delete, the tombstone's version.
17
+ * @property {string} offset The database commit offset (decimal u64), usable with shape `waitFor`.
18
+ * @property {V1Durability} durability
19
+ * @property {boolean} created True when a PUT or POST answered 201.
20
+ * @property {string | undefined} location The Location header of a POST create.
21
+ */
22
+
23
+ /**
24
+ * @param {string} what
25
+ * @returns {never}
26
+ */
27
+ export function malformed(what) {
28
+ throw new TypeError(`TerraBase returned a malformed ${what}.`);
29
+ }
30
+
31
+ /**
32
+ * @param {JsonValue | undefined} value
33
+ * @param {string} what
34
+ * @returns {JsonObject}
35
+ */
36
+ export function object(value, what) {
37
+ return isJsonObject(value) ? value : malformed(what);
38
+ }
39
+
40
+ /**
41
+ * @param {JsonValue | undefined} value
42
+ * @param {string} what
43
+ * @returns {string}
44
+ */
45
+ export function string(value, what) {
46
+ return typeof value === "string" ? value : malformed(what);
47
+ }
48
+
49
+ /**
50
+ * @param {JsonValue | undefined} value
51
+ * @param {string} what
52
+ * @returns {string}
53
+ */
54
+ export function u64(value, what) {
55
+ return parseU64(value) === undefined ? malformed(what) : /** @type {string} */ (value);
56
+ }
57
+
58
+ /**
59
+ * @param {JsonValue | undefined} value
60
+ * @param {string} what
61
+ * @returns {boolean}
62
+ */
63
+ export function boolean(value, what) {
64
+ return typeof value === "boolean" ? value : malformed(what);
65
+ }
66
+
67
+ /**
68
+ * @param {JsonValue | undefined} value
69
+ * @param {string} what
70
+ * @returns {JsonValue[]}
71
+ */
72
+ export function array(value, what) {
73
+ return Array.isArray(value) ? value : malformed(what);
74
+ }
75
+
76
+ /**
77
+ * @param {JsonValue | undefined} value
78
+ * @returns {V1Durability}
79
+ */
80
+ export function durability(value) {
81
+ return value === "local_durable" ? value : malformed("durability");
82
+ }
83
+
84
+ /**
85
+ * @param {JsonValue | undefined} value
86
+ * @returns {V1Document}
87
+ */
88
+ export function decodeDocument(value) {
89
+ const raw = object(value, "document");
90
+ const id = string(raw.id, "document id");
91
+ const version = requireVersion(raw.version);
92
+ if (raw.state !== "value") return malformed("document state");
93
+ return Object.freeze({ id, version, state: "value", document: object(raw.document, "document body") });
94
+ }
95
+
96
+ /**
97
+ * @param {{ status: number; headers: Headers; value: JsonValue | undefined }} response
98
+ * @returns {V1WriteResult}
99
+ */
100
+ export function decodeWrite(response) {
101
+ const raw = object(response.value, "write result");
102
+ return Object.freeze({
103
+ id: string(raw.id, "write id"),
104
+ version: requireVersion(raw.version),
105
+ offset: u64(raw.offset, "write offset"),
106
+ durability: durability(raw.durability),
107
+ created: response.status === 201,
108
+ location: response.headers.get("location") ?? undefined,
109
+ });
110
+ }