@dreaminhex/hexdb 0.0.0-stage → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/dist/index.d.ts +291 -0
- package/dist/index.js +455 -0
- package/package.json +45 -4
package/README.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dreaminhex/hexdb
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The JavaScript and TypeScript client for [HexDB](https://github.com/dreaminhex/hexdb), a document database with REST, GraphQL and read-only SQL, encryption at rest and replication with automatic failover.
|
|
4
|
+
|
|
5
|
+
It covers documents (insert, get, replace, patch, delete), queries with filters, sorting and paging, counts, aggregations, upserts, transactions, idempotency keys, the change feed, streams, functions and GraphQL. It retries `429` and `503` answers for reads, and for writes that carry an idempotency key. Runs on Node.js 18+, Deno, Bun and in browsers, with no dependencies.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @dreaminhex/hexdb
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { HexDB } from "@dreaminhex/hexdb"
|
|
13
|
+
|
|
14
|
+
const db = new HexDB({ url: "http://127.0.0.1:7700", apiKey: process.env.HEXDB_API_KEY })
|
|
15
|
+
const orders = db.tessellation("orders")
|
|
16
|
+
await orders.insert({ customer: "ada", total: 12 })
|
|
17
|
+
const page = await orders.query({ filter: { total: { $gt: 10 } }, sort: "-total" })
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Create an API key on the admin UI's Account page. The [driver documentation](https://github.com/dreaminhex/hexdb/tree/main/drivers) and the [manual](https://github.com/dreaminhex/hexdb/blob/main/MANUAL.md) cover the rest. Licensed under the Apache License 2.0.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
export interface Options {
|
|
2
|
+
/** Base URL of any hex, e.g. http://127.0.0.1:7700 (replicas forward writes to the Overseer). */
|
|
3
|
+
url: string;
|
|
4
|
+
/** An API key (hxk_...) or a session token. */
|
|
5
|
+
apiKey?: string;
|
|
6
|
+
/** Retries for 429 and 503 answers (honoring Retry-After). Default 3. */
|
|
7
|
+
retries?: number;
|
|
8
|
+
/** Per-request timeout in milliseconds. Default 30,000. */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
/** A fetch implementation (defaults to the global one). */
|
|
11
|
+
fetch?: typeof fetch;
|
|
12
|
+
}
|
|
13
|
+
/** An error answer from HexDB: `status` is the HTTP status, `code` HexDB's error code. */
|
|
14
|
+
export declare class HexDBError extends Error {
|
|
15
|
+
readonly status: number;
|
|
16
|
+
readonly code: string;
|
|
17
|
+
readonly retryAfter?: number | undefined;
|
|
18
|
+
constructor(status: number, code: string, message: string, retryAfter?: number | undefined);
|
|
19
|
+
}
|
|
20
|
+
/** A stored document: its fields plus `id` (and `_expires_at` with a TTL). */
|
|
21
|
+
export type Document = {
|
|
22
|
+
id: string;
|
|
23
|
+
_expires_at?: string;
|
|
24
|
+
[field: string]: unknown;
|
|
25
|
+
};
|
|
26
|
+
export interface SortKey {
|
|
27
|
+
field: string;
|
|
28
|
+
descending?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface Query {
|
|
31
|
+
/** A filter like `{ status: "paid", total: { $gte: 10 } }` (see the HexDB manual). */
|
|
32
|
+
filter?: Record<string, unknown>;
|
|
33
|
+
/** "-total,name" or sort keys. */
|
|
34
|
+
sort?: string | SortKey[];
|
|
35
|
+
limit?: number;
|
|
36
|
+
offset?: number;
|
|
37
|
+
/** Continue after this document ID (unsorted queries). */
|
|
38
|
+
after?: string;
|
|
39
|
+
/** Only these (dotted) fields. */
|
|
40
|
+
fields?: string[];
|
|
41
|
+
}
|
|
42
|
+
export interface QueryPage {
|
|
43
|
+
documents: Document[];
|
|
44
|
+
total: number;
|
|
45
|
+
next: string | null;
|
|
46
|
+
plan?: {
|
|
47
|
+
indexes: string[];
|
|
48
|
+
scanned: number;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export interface WriteOptions {
|
|
52
|
+
/** Makes retries safe: the same key returns the first result instead of writing again. */
|
|
53
|
+
idempotencyKey?: string;
|
|
54
|
+
/** Expire the document after this many seconds. */
|
|
55
|
+
ttlSeconds?: number;
|
|
56
|
+
}
|
|
57
|
+
export interface Change {
|
|
58
|
+
seq: number;
|
|
59
|
+
timestamp: string;
|
|
60
|
+
op: "put" | "delete" | "drop_tessellation";
|
|
61
|
+
tessellation: string;
|
|
62
|
+
id?: string;
|
|
63
|
+
document?: Document;
|
|
64
|
+
}
|
|
65
|
+
export interface StreamMessage {
|
|
66
|
+
offset: string;
|
|
67
|
+
time: number;
|
|
68
|
+
payload: unknown;
|
|
69
|
+
key?: string | null;
|
|
70
|
+
headers?: Record<string, string>;
|
|
71
|
+
}
|
|
72
|
+
type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
73
|
+
export declare class HexDB {
|
|
74
|
+
private readonly base;
|
|
75
|
+
private token?;
|
|
76
|
+
private readonly retries;
|
|
77
|
+
private readonly timeoutMs;
|
|
78
|
+
private readonly fetcher;
|
|
79
|
+
constructor(options: Options);
|
|
80
|
+
/** Send any request to the REST API and return the parsed JSON (undefined for 204). */
|
|
81
|
+
request<T = unknown>(method: Method, path: string, body?: unknown, headers?: Record<string, string>, timeoutMs?: number): Promise<T>;
|
|
82
|
+
/** Send raw bytes (or nothing) and get the raw response; throws HexDBError on an error answer. */
|
|
83
|
+
requestRaw(method: Method, path: string, body?: BodyInit, headers?: Record<string, string>, timeoutMs?: number): Promise<Response>;
|
|
84
|
+
/** A files tessellation (photos, audio, video, documents: any binary file). */
|
|
85
|
+
files(name: string): Files;
|
|
86
|
+
/** Sign in with a login and password (and a one-time code with MFA); later requests use the session. */
|
|
87
|
+
login(login: string, password: string, code?: string): Promise<{
|
|
88
|
+
user: unknown;
|
|
89
|
+
expires_at: number;
|
|
90
|
+
}>;
|
|
91
|
+
/** End the session (a no-op for API keys). */
|
|
92
|
+
logout(): Promise<void>;
|
|
93
|
+
/** Whether the server is up (and, signed in, which hex answered). */
|
|
94
|
+
health(): Promise<{
|
|
95
|
+
status: string;
|
|
96
|
+
name?: string;
|
|
97
|
+
hex_type?: string;
|
|
98
|
+
version?: string;
|
|
99
|
+
}>;
|
|
100
|
+
/** Server status and metrics (needs the status permission). */
|
|
101
|
+
status(): Promise<Record<string, unknown>>;
|
|
102
|
+
/** A tessellation (collection of documents). */
|
|
103
|
+
tessellation(name: string): Tessellation;
|
|
104
|
+
/** Tessellations you can read. */
|
|
105
|
+
tessellations(): Promise<{
|
|
106
|
+
name: string;
|
|
107
|
+
kind: string;
|
|
108
|
+
created: string | null;
|
|
109
|
+
indexes: string[];
|
|
110
|
+
}[]>;
|
|
111
|
+
/**
|
|
112
|
+
* Operations across tessellations, all or nothing:
|
|
113
|
+
* `[{ op: "insert" | "replace" | "patch" | "delete" | "get" | "check", tessellation, id?, data?, if_version? }]`.
|
|
114
|
+
*/
|
|
115
|
+
transaction(operations: Record<string, unknown>[], options?: {
|
|
116
|
+
idempotencyKey?: string;
|
|
117
|
+
}): Promise<{
|
|
118
|
+
results: unknown[];
|
|
119
|
+
writes: number;
|
|
120
|
+
}>;
|
|
121
|
+
/** Run a GraphQL query or mutation. Throws on GraphQL errors. */
|
|
122
|
+
graphql<T = Record<string, unknown>>(query: string, variables?: Record<string, unknown>): Promise<T>;
|
|
123
|
+
/**
|
|
124
|
+
* Committed changes after `after` (default: from now), as they happen.
|
|
125
|
+
* Long-polls; stops when `signal` aborts.
|
|
126
|
+
*/
|
|
127
|
+
changes(options?: {
|
|
128
|
+
after?: number;
|
|
129
|
+
tessellation?: string;
|
|
130
|
+
signal?: AbortSignal;
|
|
131
|
+
}): AsyncGenerator<Change>;
|
|
132
|
+
/** A stream (publish/subscribe). */
|
|
133
|
+
stream(name: string): Stream;
|
|
134
|
+
/** Run a saved function with parameters; returns its result. */
|
|
135
|
+
runFunction<T = unknown>(name: string, params?: Record<string, unknown>): Promise<T>;
|
|
136
|
+
}
|
|
137
|
+
export declare class Tessellation {
|
|
138
|
+
protected readonly db: HexDB;
|
|
139
|
+
readonly name: string;
|
|
140
|
+
constructor(db: HexDB, name: string);
|
|
141
|
+
protected get path(): string;
|
|
142
|
+
/** Insert a document; returns it with its new `id`. */
|
|
143
|
+
insert(doc: Record<string, unknown>, options?: WriteOptions): Promise<Document>;
|
|
144
|
+
/** Insert up to 1000 documents atomically; returns their IDs in order. */
|
|
145
|
+
insertMany(docs: Record<string, unknown>[], options?: WriteOptions): Promise<string[]>;
|
|
146
|
+
/** A document by ID, or null. */
|
|
147
|
+
get(id: string, fields?: string[]): Promise<Document | null>;
|
|
148
|
+
/** Replace a document's fields. */
|
|
149
|
+
replace(id: string, doc: Record<string, unknown>, options?: WriteOptions): Promise<Document>;
|
|
150
|
+
/** Merge fields into a document (null removes a field). */
|
|
151
|
+
patch(id: string, changes: Record<string, unknown>, options?: WriteOptions): Promise<Document>;
|
|
152
|
+
/** Delete a document; false if it didn't exist. */
|
|
153
|
+
delete(id: string, options?: {
|
|
154
|
+
idempotencyKey?: string;
|
|
155
|
+
}): Promise<boolean>;
|
|
156
|
+
/** Documents matching a filter, one page at a time. */
|
|
157
|
+
query(query?: Query): Promise<QueryPage>;
|
|
158
|
+
/** Every matching document, page by page (unsorted, in ID order). */
|
|
159
|
+
iterate(query?: Omit<Query, "sort" | "offset" | "after">): AsyncGenerator<Document>;
|
|
160
|
+
/** How many documents match (all of them without a filter). */
|
|
161
|
+
count(filter?: Record<string, unknown>): Promise<number>;
|
|
162
|
+
/** Group and summarize: `{ group_by: ["status"], aggregates: { total: { $sum: "amount" } } }`. */
|
|
163
|
+
aggregate(spec: Record<string, unknown>): Promise<{
|
|
164
|
+
rows: Record<string, unknown>[];
|
|
165
|
+
total_groups: number;
|
|
166
|
+
matched: number;
|
|
167
|
+
}>;
|
|
168
|
+
/** Insert or replace documents matched by key fields (e.g. an ID from another system). */
|
|
169
|
+
upsert(key: string | string[], docs: Record<string, unknown>[], options?: {
|
|
170
|
+
idempotencyKey?: string;
|
|
171
|
+
}): Promise<{
|
|
172
|
+
inserted: number;
|
|
173
|
+
replaced: number;
|
|
174
|
+
ids: string[];
|
|
175
|
+
}>;
|
|
176
|
+
/** Merge `update` into every document matching `filter`. */
|
|
177
|
+
updateWhere(filter: Record<string, unknown>, update: Record<string, unknown>): Promise<{
|
|
178
|
+
matched: number;
|
|
179
|
+
modified: number;
|
|
180
|
+
}>;
|
|
181
|
+
}
|
|
182
|
+
export declare class Stream {
|
|
183
|
+
private readonly db;
|
|
184
|
+
readonly name: string;
|
|
185
|
+
constructor(db: HexDB, name: string);
|
|
186
|
+
private get path();
|
|
187
|
+
/** Publish messages; returns their offsets. */
|
|
188
|
+
publish(...messages: {
|
|
189
|
+
payload: unknown;
|
|
190
|
+
key?: string;
|
|
191
|
+
headers?: Record<string, string>;
|
|
192
|
+
}[]): Promise<string[]>;
|
|
193
|
+
/** Messages after an offset or a group's committed offset; waits up to `wait` seconds for new ones. */
|
|
194
|
+
read(options?: {
|
|
195
|
+
after?: string;
|
|
196
|
+
group?: string;
|
|
197
|
+
limit?: number;
|
|
198
|
+
wait?: number;
|
|
199
|
+
}): Promise<{
|
|
200
|
+
messages: StreamMessage[];
|
|
201
|
+
next: string | null;
|
|
202
|
+
}>;
|
|
203
|
+
/** Commit a consumer group's offset. */
|
|
204
|
+
commit(group: string, offset: string): Promise<void>;
|
|
205
|
+
/**
|
|
206
|
+
* Consume as a group: calls `handle` for each message in order, committing
|
|
207
|
+
* after each batch it handled. Stops when `signal` aborts.
|
|
208
|
+
*/
|
|
209
|
+
consume(group: string, handle: (message: StreamMessage) => Promise<void> | void, options?: {
|
|
210
|
+
signal?: AbortSignal;
|
|
211
|
+
batch?: number;
|
|
212
|
+
}): Promise<void>;
|
|
213
|
+
}
|
|
214
|
+
export default HexDB;
|
|
215
|
+
/** A file's document: its metadata. */
|
|
216
|
+
export type FileDocument = Document & {
|
|
217
|
+
name: string;
|
|
218
|
+
path: string;
|
|
219
|
+
size: number;
|
|
220
|
+
content_type: string;
|
|
221
|
+
sha256: string;
|
|
222
|
+
version: number;
|
|
223
|
+
meta?: Record<string, unknown>;
|
|
224
|
+
extract?: {
|
|
225
|
+
status: "pending" | "done";
|
|
226
|
+
extractors?: string[];
|
|
227
|
+
errors?: string[];
|
|
228
|
+
};
|
|
229
|
+
thumbnail?: {
|
|
230
|
+
width: number;
|
|
231
|
+
height: number;
|
|
232
|
+
content_type: string;
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
export interface UploadOptions {
|
|
236
|
+
contentType?: string;
|
|
237
|
+
/** When the file was last modified (RFC 3339 or epoch milliseconds). */
|
|
238
|
+
modified?: string | number;
|
|
239
|
+
/** Bytes sent so far, for progress. */
|
|
240
|
+
onProgress?: (sent: number) => void;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* A files tessellation: each file's metadata is a document (query it like
|
|
244
|
+
* any tessellation); its content is uploaded and downloaded here. Paths are
|
|
245
|
+
* `folder/name`. Uploading to a path that has a file makes a new version.
|
|
246
|
+
*/
|
|
247
|
+
export declare class Files extends Tessellation {
|
|
248
|
+
/** Create the files tessellation (no error if it exists). */
|
|
249
|
+
create(): Promise<{
|
|
250
|
+
name: string;
|
|
251
|
+
kind: string;
|
|
252
|
+
}>;
|
|
253
|
+
/** Upload bytes or a Blob to `path`. Files of 16 MB and more go in resumable parts. */
|
|
254
|
+
upload(path: string, data: Uint8Array | ArrayBuffer | Blob, options?: UploadOptions): Promise<FileDocument>;
|
|
255
|
+
private contentPath;
|
|
256
|
+
/** A file's content (by `folder/name` or ID), or an earlier version's. */
|
|
257
|
+
download(pathOrId: string, options?: {
|
|
258
|
+
version?: number;
|
|
259
|
+
}): Promise<Uint8Array>;
|
|
260
|
+
/** A file's content as a stream (for large files). */
|
|
261
|
+
downloadStream(pathOrId: string, options?: {
|
|
262
|
+
version?: number;
|
|
263
|
+
}): Promise<ReadableStream<Uint8Array>>;
|
|
264
|
+
/** Bytes `start` to `end` (exclusive) of a file. */
|
|
265
|
+
readRange(pathOrId: string, start: number, end: number): Promise<Uint8Array>;
|
|
266
|
+
/** A file's document (metadata), or null. */
|
|
267
|
+
stat(pathOrId: string): Promise<FileDocument | null>;
|
|
268
|
+
/** The files directly in a folder, by name. */
|
|
269
|
+
list(folder?: string, limit?: number): Promise<FileDocument[]>;
|
|
270
|
+
/** Files whose name, folders, tags or extracted metadata contain every word of `text`. */
|
|
271
|
+
search(text: string, filter?: Record<string, unknown>, limit?: number): Promise<FileDocument[]>;
|
|
272
|
+
/** A small JPEG or PNG of an image (once its metadata has been read). */
|
|
273
|
+
thumbnail(id: string): Promise<Uint8Array>;
|
|
274
|
+
versions(id: string): Promise<{
|
|
275
|
+
id: string;
|
|
276
|
+
current: Record<string, unknown>;
|
|
277
|
+
versions: Record<string, unknown>[];
|
|
278
|
+
}>;
|
|
279
|
+
/** Make an earlier version current again (as a new version). */
|
|
280
|
+
restoreVersion(id: string, version: number): Promise<FileDocument>;
|
|
281
|
+
/** Deleted files (`delete` moves a file to the trash). */
|
|
282
|
+
trash(): Promise<FileDocument[]>;
|
|
283
|
+
restore(id: string, options?: {
|
|
284
|
+
name?: string;
|
|
285
|
+
path?: string;
|
|
286
|
+
}): Promise<FileDocument>;
|
|
287
|
+
/** Delete a trashed file for good. */
|
|
288
|
+
purge(id: string): Promise<void>;
|
|
289
|
+
/** Read metadata again (one file, or all); returns how many were queued. */
|
|
290
|
+
reextract(id?: string): Promise<number>;
|
|
291
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// HexDB client for Node.js (18+), Deno, Bun and browsers. No dependencies:
|
|
2
|
+
// it speaks HexDB's REST API with fetch.
|
|
3
|
+
//
|
|
4
|
+
// import { HexDB } from "@dreaminhex/hexdb"
|
|
5
|
+
// const db = new HexDB({ url: "http://127.0.0.1:7700", apiKey: process.env.HEXDB_API_KEY })
|
|
6
|
+
// const orders = db.tessellation("orders")
|
|
7
|
+
// const { id } = await orders.insert({ customer: "ada", total: 12 })
|
|
8
|
+
// const page = await orders.query({ filter: { total: { $gt: 10 } }, sort: "-total" })
|
|
9
|
+
//
|
|
10
|
+
// const media = db.files("media") // a files tessellation
|
|
11
|
+
// await media.upload("photos/IMG_1.jpg", bytes)
|
|
12
|
+
// const hits = await media.search("glacier") // names, tags and extracted metadata
|
|
13
|
+
/** An error answer from HexDB: `status` is the HTTP status, `code` HexDB's error code. */
|
|
14
|
+
export class HexDBError extends Error {
|
|
15
|
+
status;
|
|
16
|
+
code;
|
|
17
|
+
retryAfter;
|
|
18
|
+
constructor(status, code, message, retryAfter) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.retryAfter = retryAfter;
|
|
23
|
+
this.name = "HexDBError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const enc = encodeURIComponent;
|
|
27
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
28
|
+
export class HexDB {
|
|
29
|
+
base;
|
|
30
|
+
token;
|
|
31
|
+
retries;
|
|
32
|
+
timeoutMs;
|
|
33
|
+
fetcher;
|
|
34
|
+
constructor(options) {
|
|
35
|
+
this.base = options.url.replace(/\/+$/, "");
|
|
36
|
+
this.token = options.apiKey;
|
|
37
|
+
this.retries = options.retries ?? 3;
|
|
38
|
+
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
39
|
+
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
40
|
+
}
|
|
41
|
+
/** Send any request to the REST API and return the parsed JSON (undefined for 204). */
|
|
42
|
+
async request(method, path, body, headers = {}, timeoutMs = this.timeoutMs) {
|
|
43
|
+
for (let attempt = 0;; attempt++) {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
46
|
+
let response;
|
|
47
|
+
try {
|
|
48
|
+
response = await this.fetcher(this.base + path, {
|
|
49
|
+
method,
|
|
50
|
+
headers: {
|
|
51
|
+
Accept: "application/json",
|
|
52
|
+
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
|
53
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
54
|
+
...headers,
|
|
55
|
+
},
|
|
56
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
throw new HexDBError(0, "unreachable", `Couldn't reach HexDB at ${this.base}: ${e instanceof Error ? e.message : String(e)}`);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
}
|
|
66
|
+
if (response.status === 204)
|
|
67
|
+
return undefined;
|
|
68
|
+
const text = await response.text();
|
|
69
|
+
const json = text ? safeJson(text) : undefined;
|
|
70
|
+
if (response.ok)
|
|
71
|
+
return json;
|
|
72
|
+
const retryAfter = Number(response.headers.get("retry-after")) || undefined;
|
|
73
|
+
// Writes are retried only when they carry an idempotency key.
|
|
74
|
+
const retryable = (response.status === 429 || response.status === 503) && (method === "GET" || "Idempotency-Key" in headers);
|
|
75
|
+
if (retryable && attempt < this.retries) {
|
|
76
|
+
await sleep((retryAfter ?? 2 ** attempt) * 1000);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const error = json?.error;
|
|
80
|
+
throw new HexDBError(response.status, error?.code ?? "error", error?.message ?? (text || response.statusText), retryAfter);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Send raw bytes (or nothing) and get the raw response; throws HexDBError on an error answer. */
|
|
84
|
+
async requestRaw(method, path, body, headers = {}, timeoutMs = 600_000) {
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
87
|
+
let response;
|
|
88
|
+
try {
|
|
89
|
+
response = await this.fetcher(this.base + path, {
|
|
90
|
+
method,
|
|
91
|
+
headers: { ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), ...headers },
|
|
92
|
+
body,
|
|
93
|
+
signal: controller.signal,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
throw new HexDBError(0, "unreachable", `Couldn't reach HexDB at ${this.base}: ${e instanceof Error ? e.message : String(e)}`);
|
|
99
|
+
}
|
|
100
|
+
// The body may still be streaming; the timer covers the headers only.
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
const text = await response.text();
|
|
104
|
+
const error = safeJson(text)?.error;
|
|
105
|
+
throw new HexDBError(response.status, error?.code ?? "error", error?.message ?? (text || response.statusText));
|
|
106
|
+
}
|
|
107
|
+
return response;
|
|
108
|
+
}
|
|
109
|
+
/** A files tessellation (photos, audio, video, documents: any binary file). */
|
|
110
|
+
files(name) {
|
|
111
|
+
return new Files(this, name);
|
|
112
|
+
}
|
|
113
|
+
/** Sign in with a login and password (and a one-time code with MFA); later requests use the session. */
|
|
114
|
+
async login(login, password, code) {
|
|
115
|
+
const result = await this.request("POST", "/auth/login", { login, password, code, return_token: true });
|
|
116
|
+
this.token = result.token;
|
|
117
|
+
return { user: result.user, expires_at: result.expires_at };
|
|
118
|
+
}
|
|
119
|
+
/** End the session (a no-op for API keys). */
|
|
120
|
+
async logout() {
|
|
121
|
+
await this.request("POST", "/auth/logout");
|
|
122
|
+
this.token = undefined;
|
|
123
|
+
}
|
|
124
|
+
/** Whether the server is up (and, signed in, which hex answered). */
|
|
125
|
+
health() {
|
|
126
|
+
return this.request("GET", "/health");
|
|
127
|
+
}
|
|
128
|
+
/** Server status and metrics (needs the status permission). */
|
|
129
|
+
status() {
|
|
130
|
+
return this.request("GET", "/status");
|
|
131
|
+
}
|
|
132
|
+
/** A tessellation (collection of documents). */
|
|
133
|
+
tessellation(name) {
|
|
134
|
+
return new Tessellation(this, name);
|
|
135
|
+
}
|
|
136
|
+
/** Tessellations you can read. */
|
|
137
|
+
async tessellations() {
|
|
138
|
+
return (await this.request("GET", "/tessellations")).tessellations;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Operations across tessellations, all or nothing:
|
|
142
|
+
* `[{ op: "insert" | "replace" | "patch" | "delete" | "get" | "check", tessellation, id?, data?, if_version? }]`.
|
|
143
|
+
*/
|
|
144
|
+
transaction(operations, options = {}) {
|
|
145
|
+
return this.request("POST", "/transactions", { operations }, idempotency(options.idempotencyKey));
|
|
146
|
+
}
|
|
147
|
+
/** Run a GraphQL query or mutation. Throws on GraphQL errors. */
|
|
148
|
+
async graphql(query, variables) {
|
|
149
|
+
const result = await this.request("POST", "/graphql", { query, variables });
|
|
150
|
+
if (result.errors?.length) {
|
|
151
|
+
const first = result.errors[0];
|
|
152
|
+
throw new HexDBError(200, first.extensions?.code ?? "GRAPHQL", first.message);
|
|
153
|
+
}
|
|
154
|
+
return result.data;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Committed changes after `after` (default: from now), as they happen.
|
|
158
|
+
* Long-polls; stops when `signal` aborts.
|
|
159
|
+
*/
|
|
160
|
+
async *changes(options = {}) {
|
|
161
|
+
let cursor = options.after;
|
|
162
|
+
while (!options.signal?.aborted) {
|
|
163
|
+
const params = new URLSearchParams({ wait: "30", limit: "500" });
|
|
164
|
+
if (cursor !== undefined)
|
|
165
|
+
params.set("after", String(cursor));
|
|
166
|
+
if (options.tessellation)
|
|
167
|
+
params.set("tessellation", options.tessellation);
|
|
168
|
+
const page = await this.request("GET", `/changes?${params}`, undefined, {}, 60_000);
|
|
169
|
+
cursor = page.last_seq;
|
|
170
|
+
for (const change of page.changes)
|
|
171
|
+
yield change;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** A stream (publish/subscribe). */
|
|
175
|
+
stream(name) {
|
|
176
|
+
return new Stream(this, name);
|
|
177
|
+
}
|
|
178
|
+
/** Run a saved function with parameters; returns its result. */
|
|
179
|
+
async runFunction(name, params = {}) {
|
|
180
|
+
return (await this.request("POST", `/functions/${enc(name)}/run`, { params })).result;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function safeJson(text) {
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse(text);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return text;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function idempotency(key) {
|
|
192
|
+
return key ? { "Idempotency-Key": key } : {};
|
|
193
|
+
}
|
|
194
|
+
function ttl(options) {
|
|
195
|
+
return options.ttlSeconds ? `?ttl=${options.ttlSeconds}` : "";
|
|
196
|
+
}
|
|
197
|
+
export class Tessellation {
|
|
198
|
+
db;
|
|
199
|
+
name;
|
|
200
|
+
constructor(db, name) {
|
|
201
|
+
this.db = db;
|
|
202
|
+
this.name = name;
|
|
203
|
+
}
|
|
204
|
+
get path() {
|
|
205
|
+
return `/${enc(this.name)}`;
|
|
206
|
+
}
|
|
207
|
+
/** Insert a document; returns it with its new `id`. */
|
|
208
|
+
insert(doc, options = {}) {
|
|
209
|
+
return this.db.request("POST", `${this.path}${ttl(options)}`, doc, idempotency(options.idempotencyKey));
|
|
210
|
+
}
|
|
211
|
+
/** Insert up to 1000 documents atomically; returns their IDs in order. */
|
|
212
|
+
async insertMany(docs, options = {}) {
|
|
213
|
+
return (await this.db.request("POST", `${this.path}/_bulk${ttl(options)}`, docs, idempotency(options.idempotencyKey))).ids;
|
|
214
|
+
}
|
|
215
|
+
/** A document by ID, or null. */
|
|
216
|
+
async get(id, fields) {
|
|
217
|
+
try {
|
|
218
|
+
return await this.db.request("GET", `${this.path}/${enc(id)}${fields ? `?fields=${enc(fields.join(","))}` : ""}`);
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
if (e instanceof HexDBError && e.status === 404)
|
|
222
|
+
return null;
|
|
223
|
+
throw e;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** Replace a document's fields. */
|
|
227
|
+
replace(id, doc, options = {}) {
|
|
228
|
+
return this.db.request("PUT", `${this.path}/${enc(id)}${ttl(options)}`, doc, idempotency(options.idempotencyKey));
|
|
229
|
+
}
|
|
230
|
+
/** Merge fields into a document (null removes a field). */
|
|
231
|
+
patch(id, changes, options = {}) {
|
|
232
|
+
return this.db.request("PATCH", `${this.path}/${enc(id)}${ttl(options)}`, changes, idempotency(options.idempotencyKey));
|
|
233
|
+
}
|
|
234
|
+
/** Delete a document; false if it didn't exist. */
|
|
235
|
+
async delete(id, options = {}) {
|
|
236
|
+
try {
|
|
237
|
+
await this.db.request("DELETE", `${this.path}/${enc(id)}`, undefined, idempotency(options.idempotencyKey));
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
if (e instanceof HexDBError && e.status === 404)
|
|
242
|
+
return false;
|
|
243
|
+
throw e;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** Documents matching a filter, one page at a time. */
|
|
247
|
+
query(query = {}) {
|
|
248
|
+
return this.db.request("POST", `${this.path}/_query`, query);
|
|
249
|
+
}
|
|
250
|
+
/** Every matching document, page by page (unsorted, in ID order). */
|
|
251
|
+
async *iterate(query = {}) {
|
|
252
|
+
let after;
|
|
253
|
+
for (;;) {
|
|
254
|
+
const page = await this.query({ ...query, limit: query.limit ?? 500, after });
|
|
255
|
+
for (const doc of page.documents)
|
|
256
|
+
yield doc;
|
|
257
|
+
if (!page.next)
|
|
258
|
+
return;
|
|
259
|
+
after = page.next;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** How many documents match (all of them without a filter). */
|
|
263
|
+
async count(filter) {
|
|
264
|
+
const path = filter ? `${this.path}/count?filter=${enc(JSON.stringify(filter))}` : `${this.path}/count`;
|
|
265
|
+
return (await this.db.request("GET", path)).count;
|
|
266
|
+
}
|
|
267
|
+
/** Group and summarize: `{ group_by: ["status"], aggregates: { total: { $sum: "amount" } } }`. */
|
|
268
|
+
aggregate(spec) {
|
|
269
|
+
return this.db.request("POST", `${this.path}/_aggregate`, spec);
|
|
270
|
+
}
|
|
271
|
+
/** Insert or replace documents matched by key fields (e.g. an ID from another system). */
|
|
272
|
+
upsert(key, docs, options = {}) {
|
|
273
|
+
return this.db.request("POST", `${this.path}/_upsert`, { key, documents: docs }, idempotency(options.idempotencyKey));
|
|
274
|
+
}
|
|
275
|
+
/** Merge `update` into every document matching `filter`. */
|
|
276
|
+
updateWhere(filter, update) {
|
|
277
|
+
return this.db.request("POST", `${this.path}/_update`, { filter, update });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
export class Stream {
|
|
281
|
+
db;
|
|
282
|
+
name;
|
|
283
|
+
constructor(db, name) {
|
|
284
|
+
this.db = db;
|
|
285
|
+
this.name = name;
|
|
286
|
+
}
|
|
287
|
+
get path() {
|
|
288
|
+
return `/streams/${enc(this.name)}`;
|
|
289
|
+
}
|
|
290
|
+
/** Publish messages; returns their offsets. */
|
|
291
|
+
async publish(...messages) {
|
|
292
|
+
return (await this.db.request("POST", `${this.path}/messages`, messages)).offsets;
|
|
293
|
+
}
|
|
294
|
+
/** Messages after an offset or a group's committed offset; waits up to `wait` seconds for new ones. */
|
|
295
|
+
read(options = {}) {
|
|
296
|
+
const params = new URLSearchParams();
|
|
297
|
+
for (const [k, v] of Object.entries(options))
|
|
298
|
+
if (v !== undefined)
|
|
299
|
+
params.set(k, String(v));
|
|
300
|
+
return this.db.request("GET", `${this.path}/messages?${params}`, undefined, {}, ((options.wait ?? 0) + 30) * 1000);
|
|
301
|
+
}
|
|
302
|
+
/** Commit a consumer group's offset. */
|
|
303
|
+
commit(group, offset) {
|
|
304
|
+
return this.db.request("POST", `${this.path}/groups/${enc(group)}/commit`, { offset });
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Consume as a group: calls `handle` for each message in order, committing
|
|
308
|
+
* after each batch it handled. Stops when `signal` aborts.
|
|
309
|
+
*/
|
|
310
|
+
async consume(group, handle, options = {}) {
|
|
311
|
+
while (!options.signal?.aborted) {
|
|
312
|
+
const { messages, next } = await this.read({ group, limit: options.batch ?? 100, wait: 30 });
|
|
313
|
+
for (const message of messages)
|
|
314
|
+
await handle(message);
|
|
315
|
+
if (messages.length > 0 && next)
|
|
316
|
+
await this.commit(group, next);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
export default HexDB;
|
|
321
|
+
const RESUMABLE_FROM = 16 * 1024 * 1024;
|
|
322
|
+
const PART = 8 * 1024 * 1024;
|
|
323
|
+
const ULID = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
324
|
+
function filePath(path) {
|
|
325
|
+
return path
|
|
326
|
+
.split(/[\\/]/)
|
|
327
|
+
.filter(Boolean)
|
|
328
|
+
.map(enc)
|
|
329
|
+
.join("/");
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* A files tessellation: each file's metadata is a document (query it like
|
|
333
|
+
* any tessellation); its content is uploaded and downloaded here. Paths are
|
|
334
|
+
* `folder/name`. Uploading to a path that has a file makes a new version.
|
|
335
|
+
*/
|
|
336
|
+
export class Files extends Tessellation {
|
|
337
|
+
/** Create the files tessellation (no error if it exists). */
|
|
338
|
+
async create() {
|
|
339
|
+
try {
|
|
340
|
+
return await this.db.request("POST", "/tessellations", { name: this.name, kind: "files" });
|
|
341
|
+
}
|
|
342
|
+
catch (e) {
|
|
343
|
+
if (e instanceof HexDBError && e.status === 409)
|
|
344
|
+
return this.db.request("GET", `/tessellations/${enc(this.name)}`);
|
|
345
|
+
throw e;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/** Upload bytes or a Blob to `path`. Files of 16 MB and more go in resumable parts. */
|
|
349
|
+
async upload(path, data, options = {}) {
|
|
350
|
+
const blob = data instanceof Blob ? data : new Blob([data]);
|
|
351
|
+
const modified = options.modified === undefined ? undefined : String(options.modified);
|
|
352
|
+
if (blob.size < RESUMABLE_FROM) {
|
|
353
|
+
const query = modified ? `?modified=${enc(modified)}` : "";
|
|
354
|
+
const response = await this.db.requestRaw("PUT", `${this.path}/_files/${filePath(path)}${query}`, blob, options.contentType ? { "Content-Type": options.contentType } : {});
|
|
355
|
+
options.onProgress?.(blob.size);
|
|
356
|
+
return (await response.json());
|
|
357
|
+
}
|
|
358
|
+
const session = await this.db.request("POST", `${this.path}/_uploads`, { path, size: blob.size, content_type: options.contentType, modified });
|
|
359
|
+
const url = `${this.path}/_uploads/${enc(session.id)}`;
|
|
360
|
+
for (let offset = 0; offset < blob.size;) {
|
|
361
|
+
const end = Math.min(offset + PART, blob.size);
|
|
362
|
+
for (let attempt = 0;; attempt++) {
|
|
363
|
+
try {
|
|
364
|
+
await this.db.requestRaw("PATCH", `${url}?offset=${offset}`, blob.slice(offset, end));
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
if (attempt >= 3)
|
|
369
|
+
throw e;
|
|
370
|
+
await sleep(1000 * (attempt + 1));
|
|
371
|
+
const status = await this.db.request("GET", url);
|
|
372
|
+
if (status.offset >= end)
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
offset = end;
|
|
377
|
+
options.onProgress?.(offset);
|
|
378
|
+
}
|
|
379
|
+
return this.db.request("POST", `${url}/complete`, undefined, {}, 600_000);
|
|
380
|
+
}
|
|
381
|
+
contentPath(pathOrId, version) {
|
|
382
|
+
const query = version === undefined ? "" : `?version=${version}`;
|
|
383
|
+
return ULID.test(pathOrId) ? `${this.path}/${enc(pathOrId)}/content${query}` : `${this.path}/_files/${filePath(pathOrId)}${query}`;
|
|
384
|
+
}
|
|
385
|
+
/** A file's content (by `folder/name` or ID), or an earlier version's. */
|
|
386
|
+
async download(pathOrId, options = {}) {
|
|
387
|
+
const response = await this.db.requestRaw("GET", this.contentPath(pathOrId, options.version));
|
|
388
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
389
|
+
}
|
|
390
|
+
/** A file's content as a stream (for large files). */
|
|
391
|
+
async downloadStream(pathOrId, options = {}) {
|
|
392
|
+
const response = await this.db.requestRaw("GET", this.contentPath(pathOrId, options.version));
|
|
393
|
+
if (!response.body)
|
|
394
|
+
throw new HexDBError(0, "empty", "The response had no body.");
|
|
395
|
+
return response.body;
|
|
396
|
+
}
|
|
397
|
+
/** Bytes `start` to `end` (exclusive) of a file. */
|
|
398
|
+
async readRange(pathOrId, start, end) {
|
|
399
|
+
const response = await this.db.requestRaw("GET", this.contentPath(pathOrId), undefined, { Range: `bytes=${start}-${end - 1}` });
|
|
400
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
401
|
+
}
|
|
402
|
+
/** A file's document (metadata), or null. */
|
|
403
|
+
async stat(pathOrId) {
|
|
404
|
+
if (ULID.test(pathOrId))
|
|
405
|
+
return (await this.get(pathOrId));
|
|
406
|
+
const parts = pathOrId.split(/[\\/]/).filter(Boolean);
|
|
407
|
+
const name = parts.pop() ?? "";
|
|
408
|
+
const folder = parts.length ? `/${parts.join("/")}/` : "/";
|
|
409
|
+
const page = await this.query({ filter: { path: folder, name }, limit: 1 });
|
|
410
|
+
return page.documents[0] ?? null;
|
|
411
|
+
}
|
|
412
|
+
/** The files directly in a folder, by name. */
|
|
413
|
+
async list(folder = "/", limit = 1000) {
|
|
414
|
+
const parts = folder.split(/[\\/]/).filter(Boolean);
|
|
415
|
+
const path = parts.length ? `/${parts.join("/")}/` : "/";
|
|
416
|
+
return (await this.query({ filter: { path }, sort: "name", limit })).documents;
|
|
417
|
+
}
|
|
418
|
+
/** Files whose name, folders, tags or extracted metadata contain every word of `text`. */
|
|
419
|
+
async search(text, filter = {}, limit = 100) {
|
|
420
|
+
return (await this.query({ filter: { $text: text, ...filter }, limit })).documents;
|
|
421
|
+
}
|
|
422
|
+
/** A small JPEG or PNG of an image (once its metadata has been read). */
|
|
423
|
+
async thumbnail(id) {
|
|
424
|
+
const response = await this.db.requestRaw("GET", `${this.path}/${enc(id)}/thumbnail`);
|
|
425
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
426
|
+
}
|
|
427
|
+
versions(id) {
|
|
428
|
+
return this.db.request("GET", `${this.path}/${enc(id)}/versions`);
|
|
429
|
+
}
|
|
430
|
+
/** Make an earlier version current again (as a new version). */
|
|
431
|
+
restoreVersion(id, version) {
|
|
432
|
+
return this.db.request("POST", `${this.path}/${enc(id)}/versions/${version}/restore`);
|
|
433
|
+
}
|
|
434
|
+
/** Deleted files (`delete` moves a file to the trash). */
|
|
435
|
+
async trash() {
|
|
436
|
+
return (await this.db.request("GET", `${this.path}/_trash`)).documents;
|
|
437
|
+
}
|
|
438
|
+
restore(id, options = {}) {
|
|
439
|
+
const params = new URLSearchParams();
|
|
440
|
+
if (options.name)
|
|
441
|
+
params.set("name", options.name);
|
|
442
|
+
if (options.path)
|
|
443
|
+
params.set("path", options.path);
|
|
444
|
+
const query = params.toString();
|
|
445
|
+
return this.db.request("POST", `${this.path}/_trash/${enc(id)}/restore${query ? `?${query}` : ""}`);
|
|
446
|
+
}
|
|
447
|
+
/** Delete a trashed file for good. */
|
|
448
|
+
async purge(id) {
|
|
449
|
+
await this.db.request("DELETE", `${this.path}/_trash/${enc(id)}`);
|
|
450
|
+
}
|
|
451
|
+
/** Read metadata again (one file, or all); returns how many were queued. */
|
|
452
|
+
async reextract(id) {
|
|
453
|
+
return (await this.db.request("POST", `${this.path}/_reextract${id ? `?id=${enc(id)}` : ""}`)).queued;
|
|
454
|
+
}
|
|
455
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreaminhex/hexdb",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "Client for HexDB, the hexagonal document database.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Dream In Hex",
|
|
7
|
+
"homepage": "https://hexdb-website.vercel.app",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/dreaminhex/hexdb.git",
|
|
11
|
+
"directory": "drivers/node"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/dreaminhex/hexdb/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"hexdb",
|
|
18
|
+
"database",
|
|
19
|
+
"document",
|
|
20
|
+
"nosql",
|
|
21
|
+
"client"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "dist/index.js",
|
|
25
|
+
"types": "dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.json",
|
|
41
|
+
"test": "npm run build && node --test test/client.test.mjs"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"typescript": "~5.9.3",
|
|
45
|
+
"@types/node": "^22.15.18"
|
|
46
|
+
}
|
|
47
|
+
}
|