@terrascale/sdk 0.0.6 → 0.0.8
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 +18 -0
- package/README.md +66 -11
- package/package.json +2 -1
- package/sdk-file-storage-route-manifest.json +146 -0
- package/sdk-v1-route-manifest.json +39 -36
- package/src/index.js +19 -0
- package/src/local/router.js +12 -3
- package/src/v1/changes.js +2 -2
- package/src/v1/decode.js +4 -3
- package/src/v1/export.js +3 -3
- package/src/v1/file-storage-routes.js +17 -0
- package/src/v1/file-storage.js +163 -0
- package/src/v1/filter.js +103 -2
- package/src/v1/ids.js +7 -2
- package/src/v1/indexes.js +6 -4
- package/src/v1/routes.js +3 -2
- 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.js +2 -2
- package/src/v1/terrabase-v1.js +109 -9
- package/src/v1/transport.js +27 -5
- package/src/v1/usage.js +1 -1
- package/types/index.d.ts +18 -0
- package/types/v1/decode.d.ts +7 -2
- package/types/v1/file-storage-routes.d.ts +3 -0
- package/types/v1/file-storage.d.ts +98 -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/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/terrabase-v1.d.ts +72 -1
- package/types/v1/transport.d.ts +17 -0
- package/types/v1/usage.d.ts +1 -0
package/src/local/router.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { generateId } from "../ids.js";
|
|
1
|
+
import { generateId, isValidId } from "../ids.js";
|
|
2
2
|
import {
|
|
3
3
|
createHash,
|
|
4
4
|
createHmac,
|
|
@@ -171,7 +171,7 @@ export class TerraScaleLocalRouter {
|
|
|
171
171
|
);
|
|
172
172
|
if (match === null) return problemResponse(404, "unknown_route");
|
|
173
173
|
const collection = requireDatabaseId(match[1]);
|
|
174
|
-
const document =
|
|
174
|
+
const document = documentId(match[2]);
|
|
175
175
|
return await this.#store.exclusive(() => {
|
|
176
176
|
this.#binding(collection);
|
|
177
177
|
return jsonResponse(this.#point(collection, document));
|
|
@@ -752,6 +752,15 @@ function transactionKey(raw) {
|
|
|
752
752
|
const id = object(raw.transaction_id);
|
|
753
753
|
return `${text(id.expires_at_unix_ms)}:${text(id.nonce)}`;
|
|
754
754
|
}
|
|
755
|
+
/**
|
|
756
|
+
* @param {unknown} value
|
|
757
|
+
* @returns {string}
|
|
758
|
+
*/
|
|
759
|
+
function documentId(value) {
|
|
760
|
+
if (!isValidId(value)) throw new NativeLocalError(400, "invalid_document_id");
|
|
761
|
+
return value;
|
|
762
|
+
}
|
|
763
|
+
|
|
755
764
|
/**
|
|
756
765
|
* @param {unknown} value
|
|
757
766
|
* @returns {readonly DatabaseField[]}
|
|
@@ -815,7 +824,7 @@ function decodeCommit(raw) {
|
|
|
815
824
|
collection: requireDatabaseId(write.collection),
|
|
816
825
|
schema_generation: decimal(write.schema_generation),
|
|
817
826
|
schema_digest: text(write.schema_digest),
|
|
818
|
-
document:
|
|
827
|
+
document: documentId(write.document),
|
|
819
828
|
condition: /** @type {DatabaseCondition} */ (parsedCondition),
|
|
820
829
|
observed: write.observed.map((rawObserved) => {
|
|
821
830
|
const observed = object(rawObserved);
|
package/src/v1/changes.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { documentObject, malformed, object, u64 } from "./decode.js";
|
|
2
2
|
import { parseV1Error } from "./errors.js";
|
|
3
|
-
import {
|
|
3
|
+
import { requireStoredId, requireName } from "./ids.js";
|
|
4
4
|
import { parseExactJson } from "./json.js";
|
|
5
5
|
import { BodyTransportError } from "./transport.js";
|
|
6
6
|
/** @import { JsonObject } from "./json.js" */
|
|
@@ -156,7 +156,7 @@ function decodeEvent(event, headers) {
|
|
|
156
156
|
if (raw.op !== "upsert" && raw.op !== "delete") malformed("changes operation");
|
|
157
157
|
if (typeof raw.origin !== "string" || !/^[0-9a-f]{16}$/u.test(raw.origin)) malformed("changes origin");
|
|
158
158
|
return Object.freeze({ type: "change", cursor, collection: requireName("collection", raw.collection),
|
|
159
|
-
id:
|
|
159
|
+
id: requireStoredId(raw.id), op: raw.op, version: u64(raw.version, "changes version"), origin: raw.origin,
|
|
160
160
|
document: raw.document === null ? null : documentObject(raw.document),
|
|
161
161
|
});
|
|
162
162
|
}
|
package/src/v1/decode.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Typed decoding of TerraBase v1 response bodies.
|
|
2
|
-
import { parseU64, requireDocumentId, requireVersion } from "./ids.js";
|
|
2
|
+
import { parseU64, requireDocumentId, requireStoredId, requireVersion } from "./ids.js";
|
|
3
3
|
import { isJsonObject } from "./json.js";
|
|
4
4
|
/** @import { JsonObject, JsonValue } from "./json.js" */
|
|
5
5
|
|
|
@@ -13,7 +13,8 @@ import { isJsonObject } from "./json.js";
|
|
|
13
13
|
* @property {"value" | "conflict" | (string & {})} state
|
|
14
14
|
* @property {JsonObject} document
|
|
15
15
|
* @property {readonly JsonValue[]} [siblings] Live versions and tombstones on a causal conflict.
|
|
16
|
-
* @property {number} [distance] Distance to the query vector; present only on `near` query results.
|
|
16
|
+
* @property {number} [distance] Distance to the query vector, or meters to a geo `near` point; present only on `near` query results.
|
|
17
|
+
* @property {number} [score] Full-text relevance score on results sorted by `$score`.
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -100,7 +101,7 @@ export function durability(value) {
|
|
|
100
101
|
*/
|
|
101
102
|
export function decodeDocument(value) {
|
|
102
103
|
const raw = object(value, "document");
|
|
103
|
-
const id =
|
|
104
|
+
const id = requireStoredId(raw.id);
|
|
104
105
|
const version = requireVersion(raw.version);
|
|
105
106
|
const state = string(raw.state, "document state");
|
|
106
107
|
const document = documentObject(raw.document);
|
package/src/v1/export.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { documentObject, malformed, object } from "./decode.js";
|
|
2
|
-
import {
|
|
2
|
+
import { requireStoredId, requireName } from "./ids.js";
|
|
3
3
|
import { parseExactJson } from "./json.js";
|
|
4
4
|
import { BodyTransportError } from "./transport.js";
|
|
5
5
|
/** @import { JsonObject } from "./json.js" */
|
|
@@ -22,7 +22,7 @@ function decodeLine(line) {
|
|
|
22
22
|
try { value = parseExactJson(line); }
|
|
23
23
|
catch { malformed("export line JSON"); }
|
|
24
24
|
const raw = object(value, "export line");
|
|
25
|
-
return Object.freeze({ collection: requireName("collection", raw.collection), id:
|
|
25
|
+
return Object.freeze({ collection: requireName("collection", raw.collection), id: requireStoredId(raw.id), document: documentObject(raw.document) });
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -39,7 +39,7 @@ export async function* exportDocuments(transport, database, options = {}) {
|
|
|
39
39
|
if (after !== undefined) {
|
|
40
40
|
if (typeof after !== "object" || after === null) throw new TypeError("export after is { collection, id }.");
|
|
41
41
|
query.set("after_collection", requireName("collection", after.collection));
|
|
42
|
-
query.set("after_id",
|
|
42
|
+
query.set("after_id", requireStoredId(after.id));
|
|
43
43
|
}
|
|
44
44
|
const response = await transport.openStream("exportDatabase", { database }, { query, signal });
|
|
45
45
|
const body = /** @type {ReadableStream<Uint8Array>} */ (response.body);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** @typedef {"createFileBucket" | "listFileBuckets" | "setFileBucketRules" | "deleteFileBucket" | "createFileUpload" | "completeFileUpload" | "listBucketFiles" | "createFileDownload" | "deleteBucketFile"} FileStorageOperation */
|
|
2
|
+
const buckets = "/v1/projects/{project}/buckets";
|
|
3
|
+
const bucket = `${buckets}/{bucket}`;
|
|
4
|
+
const files = `${bucket}/files`;
|
|
5
|
+
const file = `${files}/{file}`;
|
|
6
|
+
/** @type {readonly import("./routes.js").V1Route[]} */
|
|
7
|
+
export const fileStorageRoutes = Object.freeze(/** @type {import("./routes.js").V1Route[]} */ ([
|
|
8
|
+
{ operation: "createFileBucket", method: "POST", pathTemplate: buckets, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [201] },
|
|
9
|
+
{ operation: "listFileBuckets", method: "GET", pathTemplate: buckets, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
|
|
10
|
+
{ operation: "setFileBucketRules", method: "PUT", pathTemplate: `${bucket}/rules`, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [200] },
|
|
11
|
+
{ operation: "deleteFileBucket", method: "DELETE", pathTemplate: bucket, permission: "documents:write", requestMediaTypes: [], successStatuses: [204] },
|
|
12
|
+
{ operation: "createFileUpload", method: "POST", pathTemplate: `${files}/upload`, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [201] },
|
|
13
|
+
{ operation: "completeFileUpload", method: "POST", pathTemplate: `${file}/complete`, permission: "documents:write", requestMediaTypes: [], successStatuses: [200] },
|
|
14
|
+
{ operation: "listBucketFiles", method: "GET", pathTemplate: files, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
|
|
15
|
+
{ operation: "createFileDownload", method: "POST", pathTemplate: `${file}/download`, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
|
|
16
|
+
{ operation: "deleteBucketFile", method: "DELETE", pathTemplate: file, permission: "documents:write", requestMediaTypes: [], successStatuses: [204] },
|
|
17
|
+
].map(route => Object.freeze({ ...route, responseMediaType: route.successStatuses.includes(204) ? "" : "application/json", idempotencyKey: "none", replaySafe: route.method === "GET", contract: "docs/file-buckets.md", contractText: [] }))));
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { array, boolean, malformed, object, string } from "./decode.js";
|
|
2
|
+
import { requireId } from "../ids.js";
|
|
3
|
+
import { assertJsonObject, stringifyExactJson } from "./json.js";
|
|
4
|
+
import { requireServiceUrl } from "./transport.js";
|
|
5
|
+
/** @import { V1Transport } from "./transport.js" */
|
|
6
|
+
/** @import { FetchLike } from "../config.js" */
|
|
7
|
+
/** @import { JsonObject, JsonValue } from "./json.js" */
|
|
8
|
+
/** @typedef {{ owner: boolean, public: boolean, roles: readonly string[] }} FileAccessPolicy */
|
|
9
|
+
/** @typedef {{ owner: string | null, read: FileAccessPolicy, write: FileAccessPolicy }} FileAccessRules */
|
|
10
|
+
/** @typedef {{ id: string, name: string, region: string, rules: FileAccessRules }} FileBucketInfo */
|
|
11
|
+
/** @typedef {{ id: string, name: string, contentType: string, size: number, sha256: string, metadata: JsonObject, status: string, uploadExpiresAt: string }} StoredFile */
|
|
12
|
+
/** @typedef {{ id: string, url: string, expiresAt: string, headers: Readonly<Record<string, string>> }} SignedFileUrl */
|
|
13
|
+
/** @typedef {{ name: string, contentType: string, size: number, sha256: string, metadata: JsonObject }} FileUploadRequest */
|
|
14
|
+
/** @typedef {{ signal?: AbortSignal | undefined }} FileRequestOptions */
|
|
15
|
+
/** @typedef {{ after?: string | undefined, signal?: AbortSignal | undefined }} FileListOptions */
|
|
16
|
+
/** @typedef {{ metadata: JsonObject, contentType?: string | undefined, signal?: AbortSignal | undefined }} FileUploadOptions */
|
|
17
|
+
const maxFileBytes = 100 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
/** @param {JsonValue | undefined} value @returns {FileAccessPolicy} */
|
|
20
|
+
function policy(value) {
|
|
21
|
+
const v = object(value, "file access policy");
|
|
22
|
+
return { owner: boolean(v.owner, "owner grant"), public: boolean(v.public, "public grant"), roles: array(v.roles, "roles").map(v => string(v, "role")) };
|
|
23
|
+
}
|
|
24
|
+
/** @param {JsonValue | undefined} value @returns {FileAccessRules} */
|
|
25
|
+
function rules(value) {
|
|
26
|
+
const v = object(value, "file access rules");
|
|
27
|
+
return { owner: v.owner === null ? null : string(v.owner, "owner pointer"), read: policy(v.read), write: policy(v.write) };
|
|
28
|
+
}
|
|
29
|
+
/** @param {JsonValue | undefined} value @returns {FileBucketInfo} */
|
|
30
|
+
function bucketInfo(value) {
|
|
31
|
+
const v = object(value, "file bucket");
|
|
32
|
+
return { id: requireId(v.id), name: string(v.name, "bucket name"), region: string(v.region, "bucket region"), rules: rules(v.rules) };
|
|
33
|
+
}
|
|
34
|
+
/** @param {JsonValue | undefined} value @returns {StoredFile} */
|
|
35
|
+
function storedFile(value) {
|
|
36
|
+
const v = object(value, "file");
|
|
37
|
+
if (typeof v.size !== "number" || !Number.isSafeInteger(v.size) || v.size < 1 || v.size > maxFileBytes) malformed("file size");
|
|
38
|
+
return { id: requireId(v.id), name: string(v.name, "file name"), contentType: string(v.contentType, "content type"), size: v.size,
|
|
39
|
+
sha256: string(v.sha256, "checksum"), metadata: object(v.metadata, "file metadata"), status: string(v.status, "file status"), uploadExpiresAt: string(v.uploadExpiresAt, "upload expiry") };
|
|
40
|
+
}
|
|
41
|
+
/** @param {JsonValue | undefined} value @returns {SignedFileUrl} */
|
|
42
|
+
function signed(value) {
|
|
43
|
+
const v = object(value, "signed file URL");
|
|
44
|
+
const url = string(v.url, "signed URL");
|
|
45
|
+
requireServiceUrl(url, "Signed file URL");
|
|
46
|
+
/** @type {Record<string, string>} */
|
|
47
|
+
const headers = {};
|
|
48
|
+
for (const [key, val] of Object.entries(object(v.headers, "signed headers"))) {
|
|
49
|
+
// Object-store capabilities must never carry the project's authorization or cookies.
|
|
50
|
+
if (!["content-type", "content-length", "x-amz-checksum-sha256"].includes(key.toLowerCase())) malformed("signed upload header");
|
|
51
|
+
headers[key] = string(val, "signed header value");
|
|
52
|
+
}
|
|
53
|
+
return { id: requireId(v.id), url, expiresAt: string(v.expiresAt, "signed URL expiry"), headers };
|
|
54
|
+
}
|
|
55
|
+
/** @param {FileListOptions} options @returns {URLSearchParams} */
|
|
56
|
+
function pageQuery(options) {
|
|
57
|
+
const query = new URLSearchParams();
|
|
58
|
+
if (options.after !== undefined) query.set("after", requireId(options.after));
|
|
59
|
+
return query;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Project file buckets, authenticated by the same token as documents. */
|
|
63
|
+
export class V1FileStorage {
|
|
64
|
+
/** @type {V1Transport} */ #transport;
|
|
65
|
+
/** @type {FetchLike} */ #fetch;
|
|
66
|
+
/** @param {V1Transport} transport @param {FetchLike} fetcher */
|
|
67
|
+
constructor(transport, fetcher) { this.#transport = transport; this.#fetch = fetcher; Object.freeze(this); }
|
|
68
|
+
/** @param {string} name @param {FileAccessRules} accessRules @param {FileRequestOptions} [options] @returns {Promise<FileBucketInfo>} */
|
|
69
|
+
async createBucket(name, accessRules, options = {}) {
|
|
70
|
+
return bucketInfo((await this.#transport.call("createFileBucket", {}, { body: stringifyExactJson({ name, rules: accessRules }, "file bucket"), contentType: "application/json", signal: options.signal })).value);
|
|
71
|
+
}
|
|
72
|
+
/** @param {FileListOptions} [options] @returns {Promise<{items: FileBucketInfo[], next: string | null}>} */
|
|
73
|
+
async listBuckets(options = {}) {
|
|
74
|
+
const v = object((await this.#transport.call("listFileBuckets", {}, { query: pageQuery(options), signal: options.signal })).value, "bucket page");
|
|
75
|
+
return { items: array(v.items, "bucket items").map(bucketInfo), next: v.next === null ? null : requireId(v.next) };
|
|
76
|
+
}
|
|
77
|
+
/** @param {string} id @returns {V1FileBucket} */
|
|
78
|
+
bucket(id) { return new V1FileBucket(this.#transport, this.#fetch, requireId(id)); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Immutable files in one project bucket. */
|
|
82
|
+
export class V1FileBucket {
|
|
83
|
+
/** @type {V1Transport} */ #transport;
|
|
84
|
+
/** @type {FetchLike} */ #fetch;
|
|
85
|
+
/** @type {string} */ #bucket;
|
|
86
|
+
/** @param {V1Transport} transport @param {FetchLike} fetcher @param {string} bucket */
|
|
87
|
+
constructor(transport, fetcher, bucket) { this.#transport = transport; this.#fetch = fetcher; this.#bucket = requireId(bucket); Object.freeze(this); }
|
|
88
|
+
/** @param {FileAccessRules} accessRules @param {FileRequestOptions} [options] @returns {Promise<FileBucketInfo>} */
|
|
89
|
+
async setRules(accessRules, options = {}) {
|
|
90
|
+
return bucketInfo((await this.#transport.call("setFileBucketRules", { bucket: this.#bucket }, { body: stringifyExactJson({ rules: accessRules }, "file bucket rules"), contentType: "application/json", signal: options.signal })).value);
|
|
91
|
+
}
|
|
92
|
+
/** @param {FileRequestOptions} [options] @returns {Promise<void>} */
|
|
93
|
+
async delete(options = {}) { await this.#transport.call("deleteFileBucket", { bucket: this.#bucket }, { signal: options.signal }); }
|
|
94
|
+
/** @param {FileUploadRequest} request @param {FileRequestOptions} [options] @returns {Promise<SignedFileUrl>} */
|
|
95
|
+
async createUpload(request, options = {}) {
|
|
96
|
+
assertJsonObject(request.metadata, "file metadata");
|
|
97
|
+
if (!Number.isSafeInteger(request.size) || request.size < 1 || request.size > maxFileBytes) throw new TypeError("Files must contain 1–104857600 bytes.");
|
|
98
|
+
return signed((await this.#transport.call("createFileUpload", { bucket: this.#bucket }, { body: stringifyExactJson(request, "file upload"), contentType: "application/json", signal: options.signal })).value);
|
|
99
|
+
}
|
|
100
|
+
/** @param {string} id @param {FileRequestOptions} [options] @returns {Promise<StoredFile>} */
|
|
101
|
+
async completeUpload(id, options = {}) {
|
|
102
|
+
return storedFile((await this.#transport.call("completeFileUpload", { bucket: this.#bucket, file: requireId(id) }, { signal: options.signal })).value);
|
|
103
|
+
}
|
|
104
|
+
/** @param {FileListOptions} [options] @returns {Promise<{items: StoredFile[], next: string | null}>} */
|
|
105
|
+
async list(options = {}) {
|
|
106
|
+
const v = object((await this.#transport.call("listBucketFiles", { bucket: this.#bucket }, { query: pageQuery(options), signal: options.signal })).value, "file page");
|
|
107
|
+
return { items: array(v.items, "file items").map(storedFile), next: v.next === null ? null : requireId(v.next) };
|
|
108
|
+
}
|
|
109
|
+
/** @param {string} id @param {FileRequestOptions} [options] @returns {Promise<SignedFileUrl>} */
|
|
110
|
+
async downloadUrl(id, options = {}) {
|
|
111
|
+
return signed((await this.#transport.call("createFileDownload", { bucket: this.#bucket, file: requireId(id) }, { signal: options.signal })).value);
|
|
112
|
+
}
|
|
113
|
+
/** @param {string} id @param {FileRequestOptions} [options] @returns {Promise<void>} */
|
|
114
|
+
async deleteFile(id, options = {}) { await this.#transport.call("deleteBucketFile", { bucket: this.#bucket, file: requireId(id) }, { signal: options.signal }); }
|
|
115
|
+
/** Hashes a private byte snapshot, uploads it without project credentials, and verifies completion. No retries.
|
|
116
|
+
* @param {string} name @param {Uint8Array | Blob} input @param {FileUploadOptions} options @returns {Promise<StoredFile>} */
|
|
117
|
+
async upload(name, input, options) {
|
|
118
|
+
const size = input instanceof Blob ? input.size : input.byteLength;
|
|
119
|
+
if (size < 1 || size > maxFileBytes) throw new TypeError("Files must contain 1–104857600 bytes.");
|
|
120
|
+
const bytes = input instanceof Blob ? new Uint8Array(await input.arrayBuffer()) : new Uint8Array(input);
|
|
121
|
+
options.signal?.throwIfAborted();
|
|
122
|
+
const hash = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
|
|
123
|
+
const sha256 = btoa(String.fromCharCode(...hash));
|
|
124
|
+
const capability = await this.createUpload({ name, contentType: options.contentType ?? "application/octet-stream", size, sha256, metadata: options.metadata }, options);
|
|
125
|
+
const headers = new Headers(capability.headers);
|
|
126
|
+
// Fetch computes Content-Length from the body; browsers prohibit setting it explicitly.
|
|
127
|
+
headers.delete("Content-Length");
|
|
128
|
+
/** @type {RequestInit & { duplex?: "half" }} */
|
|
129
|
+
const init = { method: "PUT", headers, body: bytes, credentials: "omit", redirect: "error", signal: options.signal ?? null };
|
|
130
|
+
if (typeof process !== "undefined" && process.release?.name === "node") {
|
|
131
|
+
// A one-shot stream prevents Node fetch from replaying the signed PUT on HTTP 421.
|
|
132
|
+
headers.set("Content-Length", String(size));
|
|
133
|
+
init.body = new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); } });
|
|
134
|
+
init.duplex = "half";
|
|
135
|
+
}
|
|
136
|
+
const response = await this.#fetch(capability.url, init);
|
|
137
|
+
void response.body?.cancel().catch(() => {});
|
|
138
|
+
if (!response.ok) throw new Error(`Object upload failed (${response.status}).`);
|
|
139
|
+
return this.completeUpload(capability.id, options);
|
|
140
|
+
}
|
|
141
|
+
/** Fetches file bytes without sending the project token to the object store.
|
|
142
|
+
* @param {string} id @param {FileRequestOptions} [options] @returns {Promise<Blob>} */
|
|
143
|
+
async download(id, options = {}) {
|
|
144
|
+
const capability = await this.downloadUrl(id, options);
|
|
145
|
+
const response = await this.#fetch(capability.url, { method: "GET", credentials: "omit", redirect: "error", signal: options.signal ?? null });
|
|
146
|
+
if (!response.ok) throw new Error(`Object download failed (${response.status}).`);
|
|
147
|
+
if (response.body === null) throw new Error("Object download returned no body.");
|
|
148
|
+
const reader = response.body.getReader();
|
|
149
|
+
/** @type {Uint8Array<ArrayBuffer>[]} */ const chunks = [];
|
|
150
|
+
let length = 0;
|
|
151
|
+
try {
|
|
152
|
+
for (;;) {
|
|
153
|
+
options.signal?.throwIfAborted();
|
|
154
|
+
const part = await reader.read();
|
|
155
|
+
if (part.done) break;
|
|
156
|
+
length += part.value.byteLength;
|
|
157
|
+
if (length > maxFileBytes) throw new Error("Object download exceeds the file size limit.");
|
|
158
|
+
chunks.push(new Uint8Array(part.value));
|
|
159
|
+
}
|
|
160
|
+
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
|
161
|
+
return new Blob(chunks, { type: "application/octet-stream" });
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/v1/filter.js
CHANGED
|
@@ -24,7 +24,17 @@ import { ExactDecimal } from "./json.js";
|
|
|
24
24
|
* | { readonly nin: readonly [V1Path, readonly V1Scalar[]] }
|
|
25
25
|
* | { readonly exists: V1Path }
|
|
26
26
|
* | { readonly prefix: readonly [V1Path, string] }
|
|
27
|
-
* | { readonly contains: readonly [V1Path, V1Scalar] }
|
|
27
|
+
* | { readonly contains: readonly [V1Path, V1Scalar] }
|
|
28
|
+
* | { readonly within: readonly [string, V1GeoCircle | V1GeoBox] }
|
|
29
|
+
* | { readonly search: readonly [V1Path, string] }} V1Filter
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Points within `meters` (more than 0, at most 20000000) of a WGS 84 point, by haversine distance.
|
|
33
|
+
* @typedef {{ readonly lat: number; readonly lon: number; readonly meters: number }} V1GeoCircle
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Points in a latitude/longitude box; `west > east` crosses the antimeridian.
|
|
37
|
+
* @typedef {{ readonly south: number; readonly west: number; readonly north: number; readonly east: number }} V1GeoBox
|
|
28
38
|
*/
|
|
29
39
|
|
|
30
40
|
/**
|
|
@@ -54,6 +64,28 @@ export function requirePath(path) {
|
|
|
54
64
|
throw new TypeError('A TerraBase path is an RFC 6901 JSON Pointer of 1-16 segments, "$id".');
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
/** @param {V1Path} path @returns {V1Path} */
|
|
68
|
+
function requireSearchPath(path) {
|
|
69
|
+
if (isJsonPointer(path)) return path;
|
|
70
|
+
throw new TypeError("search requires a JSON Pointer of 1-16 segments, excluding $id.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {unknown} terms @returns {string} */
|
|
74
|
+
function requireSearchTerms(terms) {
|
|
75
|
+
if (typeof terms !== "string" || terms.length === 0 || terms.includes("\0") || /[\uD800-\uDFFF]/u.test(terms) || new TextEncoder().encode(terms).byteLength > 4096)
|
|
76
|
+
throw new TypeError("search takes nonempty string terms of at most 4096 UTF-8 bytes without NUL.");
|
|
77
|
+
return terms;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {V1Filter | null | undefined} filter @returns {number} */
|
|
81
|
+
export function searchCount(filter) {
|
|
82
|
+
if (!filter) return 0;
|
|
83
|
+
if ("search" in filter) return 1;
|
|
84
|
+
if ("and" in filter) return filter.and.reduce((count, child) => count + searchCount(child), 0);
|
|
85
|
+
if ("or" in filter) return filter.or.reduce((count, child) => count + searchCount(child), 0);
|
|
86
|
+
return "not" in filter ? searchCount(filter.not) : 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
/**
|
|
58
90
|
* @param {V1Scalar} value
|
|
59
91
|
* @returns {V1Scalar}
|
|
@@ -92,6 +124,46 @@ function scalars(op, values) {
|
|
|
92
124
|
return Object.freeze(values.map(requireScalar));
|
|
93
125
|
}
|
|
94
126
|
|
|
127
|
+
/**
|
|
128
|
+
* @param {unknown} lat
|
|
129
|
+
* @param {unknown} lon
|
|
130
|
+
* @returns {void}
|
|
131
|
+
*/
|
|
132
|
+
export function requireGeoPoint(lat, lon) {
|
|
133
|
+
if (typeof lat !== "number" || typeof lon !== "number" || !(lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180))
|
|
134
|
+
throw new TypeError("lat is -90 to 90 and lon is -180 to 180.");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {unknown} area
|
|
139
|
+
* @returns {V1GeoCircle | V1GeoBox}
|
|
140
|
+
*/
|
|
141
|
+
function geoArea(area) {
|
|
142
|
+
if (typeof area !== "object" || area === null || Array.isArray(area)) throw new TypeError("within takes a circle or box object.");
|
|
143
|
+
const keys = Object.keys(area).sort().join();
|
|
144
|
+
const a = /** @type {Record<string, unknown>} */ (area);
|
|
145
|
+
if (keys === "east,north,south,west") {
|
|
146
|
+
requireGeoPoint(a.south, a.west);
|
|
147
|
+
requireGeoPoint(a.north, a.east);
|
|
148
|
+
if (/** @type {number} */ (a.south) > /** @type {number} */ (a.north)) throw new TypeError("A within box has south <= north.");
|
|
149
|
+
return Object.freeze({ south: /** @type {number} */ (a.south), west: /** @type {number} */ (a.west), north: /** @type {number} */ (a.north), east: /** @type {number} */ (a.east) });
|
|
150
|
+
}
|
|
151
|
+
if (keys !== "lat,lon,meters" || typeof a.meters !== "number" || !(a.meters > 0 && a.meters <= 20_000_000))
|
|
152
|
+
throw new TypeError("within takes {lat, lon, meters (0 to 20000000)} or {south, west, north, east}.");
|
|
153
|
+
requireGeoPoint(a.lat, a.lon);
|
|
154
|
+
return Object.freeze({ lat: /** @type {number} */ (a.lat), lon: /** @type {number} */ (a.lon), meters: a.meters });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {V1Path} path
|
|
159
|
+
* @param {unknown} area
|
|
160
|
+
* @returns {V1Filter}
|
|
161
|
+
*/
|
|
162
|
+
function within(path, area) {
|
|
163
|
+
if (requirePath(path) === "$id") throw new TypeError("within takes a JSON Pointer.");
|
|
164
|
+
return Object.freeze({ within: Object.freeze(/** @type {const} */ ([path, geoArea(area)])) });
|
|
165
|
+
}
|
|
166
|
+
|
|
95
167
|
const binaryOperators = new Set(["eq", "ne", "lt", "lte", "gt", "gte", "contains"]);
|
|
96
168
|
|
|
97
169
|
/**
|
|
@@ -119,7 +191,15 @@ export function requireFilter(filter) {
|
|
|
119
191
|
for (const child of args) visit(child, depth + 1);
|
|
120
192
|
} else if (op === "not") visit(args, depth + 1);
|
|
121
193
|
else if (op === "exists") requirePath(/** @type {V1Path} */ (args));
|
|
122
|
-
else if (
|
|
194
|
+
else if (op === "within") {
|
|
195
|
+
if (!Array.isArray(args) || args.length !== 2) throw new TypeError("within takes [path, area].");
|
|
196
|
+
within(args[0], args[1]);
|
|
197
|
+
}
|
|
198
|
+
else if (op === "search") {
|
|
199
|
+
if (!Array.isArray(args) || args.length !== 2) throw new TypeError("search takes [path, terms].");
|
|
200
|
+
requireSearchPath(args[0]);
|
|
201
|
+
requireSearchTerms(args[1]);
|
|
202
|
+
} else if (binaryOperators.has(op) || op === "prefix" || op === "in" || op === "nin") {
|
|
123
203
|
if (!Array.isArray(args) || args.length !== 2) throw new TypeError(`${op} takes [path, value].`);
|
|
124
204
|
requirePath(args[0]);
|
|
125
205
|
if (op === "in" || op === "nin") scalars(op, args[1]);
|
|
@@ -220,6 +300,13 @@ export const v1Filter = Object.freeze({
|
|
|
220
300
|
if (typeof prefix !== "string") throw new TypeError("prefix takes a string.");
|
|
221
301
|
return Object.freeze({ prefix: Object.freeze(/** @type {const} */ ([requirePath(path), prefix])) });
|
|
222
302
|
},
|
|
303
|
+
/**
|
|
304
|
+
* Full-text search on a document member.
|
|
305
|
+
* @param {V1Path} path
|
|
306
|
+
* @param {string} terms
|
|
307
|
+
* @returns {V1Filter}
|
|
308
|
+
*/
|
|
309
|
+
search: (path, terms) => Object.freeze({ search: Object.freeze(/** @type {const} */ ([requireSearchPath(path), requireSearchTerms(terms)])) }),
|
|
223
310
|
/**
|
|
224
311
|
* The value is an array containing the scalar.
|
|
225
312
|
* @param {V1Path} path
|
|
@@ -227,4 +314,18 @@ export const v1Filter = Object.freeze({
|
|
|
227
314
|
* @returns {V1Filter}
|
|
228
315
|
*/
|
|
229
316
|
contains: (path, value) => Object.freeze({ contains: Object.freeze(/** @type {const} */ ([requirePath(path), requireScalar(value)])) }),
|
|
317
|
+
/**
|
|
318
|
+
* The `{lat, lon}` object at `path` lies within `meters` of the center (haversine, WGS 84 mean radius).
|
|
319
|
+
* @param {string} path JSON Pointer of a `{lat, lon}` point.
|
|
320
|
+
* @param {V1GeoCircle} circle `meters` is more than 0, at most 20000000.
|
|
321
|
+
* @returns {V1Filter}
|
|
322
|
+
*/
|
|
323
|
+
withinRadius: (path, circle) => within(path, { lat: circle?.lat, lon: circle?.lon, meters: circle?.meters }),
|
|
324
|
+
/**
|
|
325
|
+
* The `{lat, lon}` object at `path` lies in the box; `west > east` crosses the antimeridian.
|
|
326
|
+
* @param {string} path JSON Pointer of a `{lat, lon}` point.
|
|
327
|
+
* @param {V1GeoBox} box
|
|
328
|
+
* @returns {V1Filter}
|
|
329
|
+
*/
|
|
330
|
+
withinBox: (path, box) => within(path, { south: box?.south, west: box?.west, north: box?.north, east: box?.east }),
|
|
230
331
|
});
|
package/src/v1/ids.js
CHANGED
|
@@ -27,11 +27,16 @@ export function requireProject(project) {
|
|
|
27
27
|
return requireId(project);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/** Every document id is a canonical ULID. @param {unknown} id @returns {string} */
|
|
31
31
|
export function requireDocumentId(id) {
|
|
32
|
+
return requireId(id, "TerraBase document ID");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** SQL-backed collections can return non-ULID primary keys. @param {unknown} id @returns {string} */
|
|
36
|
+
export function requireStoredId(id) {
|
|
32
37
|
if (typeof id !== "string" || id.length < 1 || id.length > 256 ||
|
|
33
38
|
/[\u0000-\u001f\u007f-\u009f\ud800-\udfff]/u.test(id))
|
|
34
|
-
throw new TypeError("TerraBase
|
|
39
|
+
throw new TypeError("TerraBase stored IDs must be 1-256 characters of valid UTF-8 without control characters.");
|
|
35
40
|
return id;
|
|
36
41
|
}
|
|
37
42
|
|
package/src/v1/indexes.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { requireName } from "./ids.js";
|
|
2
|
-
import { flag, list, numeric, record, text } from "./management-decode.js";
|
|
2
|
+
import { flag, list, numeric, optional, record, text } from "./management-decode.js";
|
|
3
3
|
import { members, mutationOptions } from "./management-validation.js";
|
|
4
4
|
import { stringifyExactJson } from "./json.js";
|
|
5
5
|
/** @import { V1Transport } from "./transport.js" */
|
|
6
6
|
/** @import { V1RequestOptions } from "./terrabase-v1.js" */
|
|
7
|
-
const index = record({ name: text, collection: text, fields: list(text), state: text });
|
|
7
|
+
const index = record({ name: text, collection: text, fields: list(text), state: text, kind: optional(text) });
|
|
8
8
|
const indexes = record({ items: list(index) });
|
|
9
9
|
const policy = record({ collection: text, enabled: flag });
|
|
10
10
|
const recommendations = record({ items: list(record({ id: text, collection: text, fields: list(text), reason: text, queries: numeric })) });
|
|
@@ -14,7 +14,8 @@ const applied = record({ status: text, index });
|
|
|
14
14
|
/** @typedef {ReturnType<typeof policy>} V1AutoIndexPolicy */
|
|
15
15
|
/** @typedef {ReturnType<typeof recommendations>} V1IndexRecommendations */
|
|
16
16
|
/** @typedef {ReturnType<typeof applied>} V1AppliedIndexRecommendation */
|
|
17
|
-
/** @typedef {
|
|
17
|
+
/** @typedef {"btree" | "text"} V1IndexKind */
|
|
18
|
+
/** @typedef {{ readonly collection: string; readonly fields: readonly string[]; readonly name?: string | null; readonly kind?: V1IndexKind }} V1CreateIndexRequest */
|
|
18
19
|
/** @param {unknown} value @returns {string} */
|
|
19
20
|
function indexName(value) {
|
|
20
21
|
if (typeof value !== "string" || !/^[a-z][a-z0-9_]{0,62}$/u.test(value)) throw new TypeError("Invalid index name.");
|
|
@@ -31,7 +32,8 @@ export class V1Indexes {
|
|
|
31
32
|
constructor(transport, database) { this.#transport = transport; this.#params = { database: requireName("database", database) }; Object.freeze(this); }
|
|
32
33
|
/** @param {V1CreateIndexRequest} request @param {V1RequestOptions} [options] @returns {Promise<V1Index>} */
|
|
33
34
|
async create(request, options = {}) {
|
|
34
|
-
members(request, ["collection", "fields", "name"]);
|
|
35
|
+
members(request, ["collection", "fields", "name", "kind"]);
|
|
36
|
+
if (request.kind !== undefined && request.kind !== "btree" && request.kind !== "text") throw new TypeError("kind must be btree or text.");
|
|
35
37
|
requireName("collection", request.collection);
|
|
36
38
|
if (!Array.isArray(request.fields) || request.fields.length < 1 || request.fields.length > 4 ||
|
|
37
39
|
new Set(request.fields).size !== request.fields.length || request.fields.some(f => typeof f !== "string" || f === "_id" || f === "_rev" || !/^[A-Za-z_][A-Za-z0-9_]{0,63}(?:\.[A-Za-z_][A-Za-z0-9_]{0,63}){0,7}$/u.test(f)))
|
package/src/v1/routes.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { fileStorageRoutes } from "./file-storage-routes.js";
|
|
1
2
|
import { managementV1Routes } from "./management-routes.js";
|
|
2
3
|
// Current TerraBase routes at e30ffdc; readShape is retained from release 6cb9745 only.
|
|
3
|
-
/** @typedef {import("./management-routes.js").ManagementOperation | "readFreeShape" | "getShapeKey" | "getDocument" | "putDocument" | "createDocument" | "patchDocument" | "deleteDocument" | "commitTransaction" | "queryDocuments" | "createCollection" | "getCollection" | "listCollections" | "dropCollection" | "readShape" | "runSql" | "checkSql" | "explainSql" | "streamDatabaseChanges" | "listDocuments" | "getCollectionSchema" | "putCollectionSchema" | "deleteCollectionSchema" | "createDatabase" | "listDatabases" | "getDatabase" | "updateDatabase" | "deleteDatabase" | "branchDatabase"} V1Operation */
|
|
4
|
+
/** @typedef {import("./file-storage-routes.js").FileStorageOperation | import("./management-routes.js").ManagementOperation | "readFreeShape" | "getShapeKey" | "getDocument" | "putDocument" | "createDocument" | "patchDocument" | "deleteDocument" | "commitTransaction" | "queryDocuments" | "createCollection" | "getCollection" | "listCollections" | "dropCollection" | "readShape" | "runSql" | "checkSql" | "explainSql" | "streamDatabaseChanges" | "listDocuments" | "getCollectionSchema" | "putCollectionSchema" | "deleteCollectionSchema" | "createDatabase" | "listDatabases" | "getDatabase" | "updateDatabase" | "deleteDatabase" | "branchDatabase"} V1Operation */
|
|
4
5
|
/**
|
|
5
6
|
* @typedef {object} V1Route
|
|
6
7
|
* @property {V1Operation} operation
|
|
@@ -61,7 +62,7 @@ export const v1Routes = Object.freeze(/** @type {V1Route[]} */ ([
|
|
|
61
62
|
})));
|
|
62
63
|
/** @param {V1Operation} operation @returns {V1Route} */
|
|
63
64
|
export function v1Route(operation) {
|
|
64
|
-
const route = [...v1Routes, ...shapeV1Routes, ...managementV1Routes].find(candidate => candidate.operation === operation);
|
|
65
|
+
const route = [...v1Routes, ...shapeV1Routes, ...managementV1Routes, ...fileStorageRoutes].find(candidate => candidate.operation === operation);
|
|
65
66
|
if (route === undefined) throw new TypeError(`Unknown v1 operation ${operation}.`);
|
|
66
67
|
return route;
|
|
67
68
|
}
|
package/src/v1/shape-reader.js
CHANGED
|
@@ -5,6 +5,7 @@ import { decodeShapePage, encodeShapeRequest, requireShapeHandle, requireShapeOf
|
|
|
5
5
|
import { decryptShapeObject } from "./shape-crypto.js";
|
|
6
6
|
import { parseExactJson } from "./json.js";
|
|
7
7
|
import { TerraBaseV1Error } from "./errors.js";
|
|
8
|
+
import { OpaqueRedirectError } from "./transport.js";
|
|
8
9
|
/** @import { V1Transport } from "./transport.js" */
|
|
9
10
|
/** @import { V1ShapeRequest, V1ShapePage } from "./shapes.js" */
|
|
10
11
|
/** @typedef {{handle: string; offset: string; epoch: string; keyId: string}} V1SealedShapePosition */
|
|
@@ -125,11 +126,9 @@ export async function readShapePage(transport, params, request, signal, ifNoneMa
|
|
|
125
126
|
query.delete("cursor");
|
|
126
127
|
}
|
|
127
128
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const location = response.headers.get("location");
|
|
132
|
-
if (location === null) malformed("shape redirect Location");
|
|
129
|
+
const bootstrap = query.get("offset") === "-1";
|
|
130
|
+
/** @param {string} location */
|
|
131
|
+
const redirectTarget = location => {
|
|
133
132
|
const target = new URL(location, source);
|
|
134
133
|
if (target.origin !== source.origin || target.pathname !== source.pathname || target.username !== "" || target.password !== "" || target.hash !== "") malformed("shape redirect target");
|
|
135
134
|
for (const key of target.searchParams.keys()) {
|
|
@@ -144,6 +143,21 @@ export async function readShapePage(transport, params, request, signal, ifNoneMa
|
|
|
144
143
|
delete headers["TB-Shape-Resume"];
|
|
145
144
|
query.set("handle", handle);
|
|
146
145
|
query.set("offset", offset);
|
|
146
|
+
};
|
|
147
|
+
let response;
|
|
148
|
+
try { response = await shapeCall(transport, params, "readFreeShape", { query, headers, signal }); }
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (!(error instanceof OpaqueRedirectError) || !bootstrap) throw error;
|
|
151
|
+
// Browsers hide a manual 307; follow it natively, then hold the final URL to the Location rules.
|
|
152
|
+
response = await shapeCall(transport, params, "readFreeShape", { query: new URLSearchParams(query), headers: { ...headers }, signal, redirect: "follow" });
|
|
153
|
+
if (response.redirectedTo === undefined || response.status !== 200) malformed("shape bootstrap requires redirect");
|
|
154
|
+
redirectTarget(response.redirectedTo);
|
|
155
|
+
}
|
|
156
|
+
if (response.status === 307) {
|
|
157
|
+
if (!bootstrap) malformed("unexpected shape redirect");
|
|
158
|
+
const location = response.headers.get("location");
|
|
159
|
+
if (location === null) malformed("shape redirect Location");
|
|
160
|
+
redirectTarget(location);
|
|
147
161
|
response = await shapeCall(transport, params, "readFreeShape", { query, headers, signal });
|
|
148
162
|
if (response.status !== 200) malformed("repeated shape redirect");
|
|
149
163
|
} else if (query.get("offset") === "-1") malformed("shape bootstrap requires redirect");
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Browser storage for a shape subscription's read replica. Durable writes never queue here.
|
|
2
|
+
/** @import { V1ShapeReplicaStore } from "./shape-subscription.js" */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A V1ShapeReplicaStore kept in browser IndexedDB, one record per key.
|
|
6
|
+
* @param {string} key Names one subject's shape, e.g. `${userId}/notes/open`; clear it on sign-out.
|
|
7
|
+
* @returns {V1ShapeReplicaStore}
|
|
8
|
+
*/
|
|
9
|
+
export function indexedDbShapeReplica(key) {
|
|
10
|
+
if (typeof key !== "string" || key === "") throw new TypeError("An IndexedDB replica key is a non-empty string.");
|
|
11
|
+
/** @type {Promise<IDBDatabase> | undefined} */
|
|
12
|
+
let opened;
|
|
13
|
+
/** @returns {Promise<IDBDatabase>} */
|
|
14
|
+
const open = () => opened ??= new Promise((resolve, reject) => {
|
|
15
|
+
const request = indexedDB.open("terrascale-shape-replicas", 1);
|
|
16
|
+
request.onupgradeneeded = () => { request.result.createObjectStore("replicas"); };
|
|
17
|
+
request.onsuccess = () => resolve(request.result);
|
|
18
|
+
request.onerror = () => { opened = undefined; reject(request.error); };
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* @template T
|
|
22
|
+
* @param {IDBTransactionMode} mode
|
|
23
|
+
* @param {(store: IDBObjectStore) => IDBRequest<T>} operation
|
|
24
|
+
* @returns {Promise<T>}
|
|
25
|
+
*/
|
|
26
|
+
const run = async (mode, operation) => {
|
|
27
|
+
const database = await open();
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const transaction = database.transaction("replicas", mode);
|
|
30
|
+
const request = operation(transaction.objectStore("replicas"));
|
|
31
|
+
transaction.oncomplete = () => resolve(request.result);
|
|
32
|
+
transaction.onerror = transaction.onabort = () => reject(transaction.error ?? request.error);
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
load: async () => {
|
|
37
|
+
const value = await run("readonly", store => store.get(key));
|
|
38
|
+
return typeof value === "string" ? value : undefined;
|
|
39
|
+
},
|
|
40
|
+
/** @param {string | undefined} text */
|
|
41
|
+
save: async text => {
|
|
42
|
+
if (text === undefined) await run("readwrite", store => store.delete(key));
|
|
43
|
+
else await run("readwrite", store => store.put(text, key));
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|