stratus-db 0.0.0-stage → 0.1.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 +75 -2
- package/package.json +8 -4
- package/stratus-db.js +821 -0
package/README.md
CHANGED
|
@@ -1,3 +1,76 @@
|
|
|
1
|
-
#
|
|
1
|
+
# stratus-db
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The stratus service from JavaScript: typed JSON over `fetch` (docs/json.md), no dependencies.
|
|
4
|
+
Node 20+ or a browser.
|
|
5
|
+
|
|
6
|
+
The client is one ES module, `stratus-db.js`. Install it from npm (`npm install stratus-db`: the
|
|
7
|
+
package holds only that file, this README and a `package.json` with no scripts, published from
|
|
8
|
+
this repository's release workflow), or copy the file into your project. To pin it, use your
|
|
9
|
+
lockfile's integrity hash, or compare the file's sha256 with the one published beside it.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { connect, kw, squuid, decimal, instant, localDate } from "stratus-db";
|
|
13
|
+
|
|
14
|
+
const s = connect({ url: "https://api.stratus-db.com", apiKey: process.env.STRATUS_KEY });
|
|
15
|
+
const { id } = await s.createDb({ name: "invoices", primaryKey: "invoice/id" });
|
|
16
|
+
|
|
17
|
+
await s.transact(id, { tx: [{
|
|
18
|
+
"invoice/id": squuid(), // every row carries its key
|
|
19
|
+
"invoice/total": decimal("1234.50"), // exact, scale kept
|
|
20
|
+
"invoice/due": localDate("2026-11-01"),
|
|
21
|
+
"invoice/sent": instant(new Date()),
|
|
22
|
+
"invoice/status": kw("status/sent"), // a keyword, not the string "status/sent"
|
|
23
|
+
}], txId: "import-42" }); // a retry with the same id applies once
|
|
24
|
+
|
|
25
|
+
await s.requestIndex(id, "invoice/status");
|
|
26
|
+
await s.awaitIndex(id, "invoice/status");
|
|
27
|
+
const { rows, next } = await s.query(id, { where: ["=", "invoice/status", kw("status/sent")], limit: 100 });
|
|
28
|
+
for await (const row of s.queryAll(id, { where: ["range", "invoice/due", localDate("2026-11-01"), null] })) { /* ... */ }
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Values
|
|
32
|
+
|
|
33
|
+
| stratus | JavaScript |
|
|
34
|
+
|---|---|
|
|
35
|
+
| string, boolean, nil | string, boolean, `null` |
|
|
36
|
+
| long | `number` within ±2^53, else `bigint` (write either) |
|
|
37
|
+
| double | `number` (an integral one is written as a long: `double(1)` keeps a double) |
|
|
38
|
+
| BigDecimal | `Decimal` (its text; no arithmetic) |
|
|
39
|
+
| BigInteger, ratio | `BigInteger`, `Ratio` |
|
|
40
|
+
| keyword, symbol, char | `Keyword` (`kw("a/b")`, interned), `Sym`, `Char` |
|
|
41
|
+
| UUID | `Uuid` (`uuid("...")`, `squuid()`; interned) |
|
|
42
|
+
| instant | `Instant` (nanoseconds; `instant(date or ISO)`, `.toDate()`); a `Date` writes as one |
|
|
43
|
+
| local date | `LocalDate` (`localDate("2026-10-09")`) |
|
|
44
|
+
| bytes | `Uint8Array` |
|
|
45
|
+
| vector, list, set | `Array`, `List` (`list(1, 2)`), `Set` |
|
|
46
|
+
| map | a plain object (keys are attribute names); `Map` when keys aren't keywords |
|
|
47
|
+
|
|
48
|
+
A UUID, instant or keyword passed as a plain string is stored as a string and matches nothing
|
|
49
|
+
of the intended type.
|
|
50
|
+
|
|
51
|
+
## API
|
|
52
|
+
|
|
53
|
+
All methods return promises; failures reject with `StratusError` (`status`, `error` - e.g.
|
|
54
|
+
`"invalid-query"`, `"conflict"` -, `message`). 429s are retried with backoff (`retries`).
|
|
55
|
+
|
|
56
|
+
- Catalog: `listDbs({deleted})`, `dbByName(name)`, `dbRecord(id)`, `createDb({name, primaryKey})`,
|
|
57
|
+
`renameDb(id, name)`, `deleteDb(id)`.
|
|
58
|
+
- A database: `status(id)`, `transact(id, {tx, retract, delete, txId, match})`, `query(id, q)`,
|
|
59
|
+
`queryAll(id, q)` (async iterator), `pull(id, pks)`, `requestIndex(id, att)`,
|
|
60
|
+
`awaitIndex(id, att)`, `dropIndex(id, att)`, `history(id, {from, limit})`, `historyAll`,
|
|
61
|
+
`fork(id, {t, name})` (hosted: a new database, this one as of t), `excise(id, {rows,
|
|
62
|
+
attributes})` (erasure: rows and {pk, att} out of the state, indexes, history and forks).
|
|
63
|
+
- Keys (hosted, with a key reaching every database): `listKeys()`, `createKey({name, scope})`,
|
|
64
|
+
`revokeKey(keyId)`.
|
|
65
|
+
- Codec: `encode`, `decode` (typed-JSON text), `toJSONValue`, `fromJSONValue`.
|
|
66
|
+
|
|
67
|
+
Queries: docs/query.md ("JSON"). Addressing: `"hosted"` (default: `<url>/v1/dbs/<id>/...`,
|
|
68
|
+
`Authorization: Bearer <apiKey>`) or `"function-urls"` (the internal service's Function URLs,
|
|
69
|
+
resolved through its catalog; `caller` for a local service).
|
|
70
|
+
|
|
71
|
+
## Tests
|
|
72
|
+
|
|
73
|
+
`node --test test/ejson.test.js` (the codec: velojity's own output in
|
|
74
|
+
`clients/fixtures/velojity-ejson.json`, which stratus's client-fixtures-test keeps current), and
|
|
75
|
+
`scripts/test-js-client` from the repo root: the codec, then the client against the local
|
|
76
|
+
service.
|
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stratus-db",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The stratus-db service from JavaScript: typed JSON over fetch, no dependencies.",
|
|
5
|
+
"homepage": "https://stratus-db.com",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": "./stratus-db.js",
|
|
8
|
+
"files": ["stratus-db.js"],
|
|
9
|
+
"engines": { "node": ">=20" }
|
|
10
|
+
}
|
package/stratus-db.js
ADDED
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
// stratus-db: the stratus-db service from JavaScript (Node 20+, browsers), typed JSON over
|
|
2
|
+
// fetch. No dependencies, and one file: the same file is on npm and at
|
|
3
|
+
// stratus-db.com/clients/, with its sha256.
|
|
4
|
+
//
|
|
5
|
+
// The stratus client: typed JSON over fetch (docs/json.md), 429s retried with backoff, pages
|
|
6
|
+
// followed. Every method returns a promise; failures reject with a StratusError.
|
|
7
|
+
//
|
|
8
|
+
// import { connect, kw, squuid } from "stratus-db";
|
|
9
|
+
// const s = connect({ url: "https://api.stratus-db.com", apiKey: process.env.STRATUS_KEY });
|
|
10
|
+
// const { id } = await s.createDb({ name: "orders", primaryKey: "order/id" });
|
|
11
|
+
// await s.transact(id, { tx: [{ "order/id": squuid(), "order/status": kw("status/new") }] });
|
|
12
|
+
// const { rows } = await s.query(id, { where: ["=", "order/status", kw("status/new")] });
|
|
13
|
+
//
|
|
14
|
+
// Addressing: "hosted" (the default) - the catalog at <url>/v1/dbs, a database at
|
|
15
|
+
// <url>/v1/dbs/<id>/..., the API key as "Authorization: Bearer <key>". "function-urls" - the
|
|
16
|
+
// internal service: the catalog at url, each database at the Function URL its catalog record
|
|
17
|
+
// names (looked up, cached for resolveMs, and again when one answers 403 or 404); for testing
|
|
18
|
+
// against a local service, opts.caller names the caller (x-stratus-caller).
|
|
19
|
+
|
|
20
|
+
/** The client's version: package.json's, which release-js.yml checks against it. */
|
|
21
|
+
export const VERSION = "0.1.0";
|
|
22
|
+
|
|
23
|
+
const USER_AGENT = `stratus-db-js/${VERSION}`;
|
|
24
|
+
const IN_NODE = typeof process !== "undefined" && process.versions != null && process.versions.node != null;
|
|
25
|
+
|
|
26
|
+
// ------------------------------------------------------------------ Values
|
|
27
|
+
// The value types stratus stores that JavaScript has no exact equivalent for. Each is what a
|
|
28
|
+
// typed-JSON tag reads as (docs/json.md), and writes as that tag again.
|
|
29
|
+
//
|
|
30
|
+
// Strings stay strings: a UUID, an instant or a keyword must be one of these, or it is stored
|
|
31
|
+
// as a string and matches nothing of the intended type.
|
|
32
|
+
|
|
33
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
34
|
+
|
|
35
|
+
const uuids = new Map();
|
|
36
|
+
// a collected UUID's entry goes too
|
|
37
|
+
const uuidCleanup = new FinalizationRegistry((t) => { if (uuids.get(t)?.deref() === undefined) uuids.delete(t); });
|
|
38
|
+
|
|
39
|
+
/** A UUID. Interned: equal UUIDs are the same object, so === and Set membership work. */
|
|
40
|
+
export class Uuid {
|
|
41
|
+
/** @param {string} text */
|
|
42
|
+
constructor(text) {
|
|
43
|
+
this.text = text;
|
|
44
|
+
Object.freeze(this);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The UUID of text (any case; written lowercase). */
|
|
48
|
+
static of(text) {
|
|
49
|
+
const t = String(text).toLowerCase();
|
|
50
|
+
if (!UUID_RE.test(t)) throw new TypeError(`Not a UUID: ${text}`);
|
|
51
|
+
let u = uuids.get(t)?.deref();
|
|
52
|
+
if (u === undefined) {
|
|
53
|
+
u = new Uuid(t);
|
|
54
|
+
uuids.set(t, new WeakRef(u));
|
|
55
|
+
uuidCleanup.register(u, t);
|
|
56
|
+
}
|
|
57
|
+
return u;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
toString() { return this.text; }
|
|
61
|
+
toJSON() { return this.text; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const keywords = new Map();
|
|
65
|
+
|
|
66
|
+
/** A keyword (:status/sent). Interned: equal keywords are the same object. */
|
|
67
|
+
export class Keyword {
|
|
68
|
+
constructor(namespace, name) {
|
|
69
|
+
this.namespace = namespace;
|
|
70
|
+
this.name = name;
|
|
71
|
+
Object.freeze(this);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The keyword named "ns/name" (or "name"). A leading ":" is dropped. */
|
|
75
|
+
static of(text) {
|
|
76
|
+
let t = String(text);
|
|
77
|
+
if (t.startsWith(":")) t = t.slice(1);
|
|
78
|
+
if (t === "") throw new TypeError("A keyword has a name");
|
|
79
|
+
let k = keywords.get(t);
|
|
80
|
+
if (k === undefined) {
|
|
81
|
+
const i = t.indexOf("/");
|
|
82
|
+
k = i <= 0 || t.length === 1 ? new Keyword(null, t) : new Keyword(t.slice(0, i), t.slice(i + 1));
|
|
83
|
+
keywords.set(t, k);
|
|
84
|
+
}
|
|
85
|
+
return k;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** "ns/name", or "name". */
|
|
89
|
+
get fullName() { return this.namespace === null ? this.name : `${this.namespace}/${this.name}`; }
|
|
90
|
+
toString() { return `:${this.fullName}`; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A symbol (rare in data; kept so values read and written round-trip). */
|
|
94
|
+
export class Sym {
|
|
95
|
+
constructor(fullName) { this.fullName = String(fullName); Object.freeze(this); }
|
|
96
|
+
toString() { return this.fullName; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A single character. */
|
|
100
|
+
export class Char {
|
|
101
|
+
constructor(c) {
|
|
102
|
+
const s = String(c);
|
|
103
|
+
if ([...s].length !== 1) throw new TypeError(`A char is one character: ${c}`);
|
|
104
|
+
this.value = s;
|
|
105
|
+
Object.freeze(this);
|
|
106
|
+
}
|
|
107
|
+
toString() { return this.value; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* An exact decimal (java.math.BigDecimal), kept as its text so its scale survives: 1.10 stays
|
|
112
|
+
* "1.10". No arithmetic: convert with a decimal library of your choice.
|
|
113
|
+
*/
|
|
114
|
+
export class Decimal {
|
|
115
|
+
constructor(text) {
|
|
116
|
+
const t = String(text);
|
|
117
|
+
if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(t)) throw new TypeError(`Not a decimal: ${text}`);
|
|
118
|
+
this.text = t;
|
|
119
|
+
Object.freeze(this);
|
|
120
|
+
}
|
|
121
|
+
toString() { return this.text; }
|
|
122
|
+
toNumber() { return Number(this.text); }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** An arbitrary-size integer (java.math.BigInteger), distinct from a long. */
|
|
126
|
+
export class BigInteger {
|
|
127
|
+
/** @param {bigint|string|number} v */
|
|
128
|
+
constructor(v) { this.value = BigInt(v); Object.freeze(this); }
|
|
129
|
+
toString() { return this.value.toString(); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** An exact ratio of two integers (1/3). */
|
|
133
|
+
export class Ratio {
|
|
134
|
+
constructor(numerator, denominator) {
|
|
135
|
+
this.numerator = BigInt(numerator);
|
|
136
|
+
this.denominator = BigInt(denominator);
|
|
137
|
+
if (this.denominator === 0n) throw new RangeError("A ratio's denominator isn't 0");
|
|
138
|
+
Object.freeze(this);
|
|
139
|
+
}
|
|
140
|
+
toString() { return `${this.numerator}/${this.denominator}`; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A double whose value is integral (1.0). JavaScript's numbers can't say long or double, and
|
|
145
|
+
* an integral number is written as a long; wrap it to store a double.
|
|
146
|
+
*/
|
|
147
|
+
export class Double {
|
|
148
|
+
constructor(v) { this.value = Number(v); Object.freeze(this); }
|
|
149
|
+
valueOf() { return this.value; }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A list (as opposed to a vector, which is an Array). */
|
|
153
|
+
export class List extends Array {
|
|
154
|
+
/** A List of the items of iterable. */
|
|
155
|
+
static from(iterable) {
|
|
156
|
+
const l = new List();
|
|
157
|
+
for (const x of iterable) l.push(x);
|
|
158
|
+
return l;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ISO_INSTANT = /^([+-]\d{4,}|\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?Z$/;
|
|
163
|
+
|
|
164
|
+
function pad(n, width) { return String(n).padStart(width, "0"); }
|
|
165
|
+
|
|
166
|
+
function yearText(y) {
|
|
167
|
+
if (y >= 0 && y <= 9999) return pad(y, 4);
|
|
168
|
+
return (y < 0 ? "-" : "+") + pad(Math.abs(y), 4);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseYear(s) { return Number(s); }
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A point in time with nanosecond precision (java.time.Instant): epochSecond and nano.
|
|
175
|
+
* JavaScript's Date keeps milliseconds; toDate() truncates to them.
|
|
176
|
+
*/
|
|
177
|
+
export class Instant {
|
|
178
|
+
constructor(epochSecond, nano = 0) {
|
|
179
|
+
if (!Number.isSafeInteger(epochSecond)) throw new RangeError(`epochSecond: ${epochSecond}`);
|
|
180
|
+
if (!Number.isInteger(nano) || nano < 0 || nano > 999999999) throw new RangeError(`nano: ${nano}`);
|
|
181
|
+
this.epochSecond = epochSecond;
|
|
182
|
+
this.nano = nano;
|
|
183
|
+
Object.freeze(this);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The instant of a Date (millisecond precision). */
|
|
187
|
+
static fromDate(d) {
|
|
188
|
+
const ms = d.getTime();
|
|
189
|
+
if (Number.isNaN(ms)) throw new RangeError("An invalid Date");
|
|
190
|
+
const s = Math.floor(ms / 1000);
|
|
191
|
+
return new Instant(s, (ms - s * 1000) * 1000000);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The instant of ISO-8601 UTC text, as Java writes it ("2026-10-09T14:23:05.123456Z"). */
|
|
195
|
+
static parse(text) {
|
|
196
|
+
const m = ISO_INSTANT.exec(text);
|
|
197
|
+
if (m === null) throw new RangeError(`Not an ISO instant: ${text}`);
|
|
198
|
+
const [, y, mo, d, h, mi, s, frac] = m;
|
|
199
|
+
// Date.UTC maps years 0-99 to 1900-1999: start in a leap year, then set the real date
|
|
200
|
+
const date = new Date(Date.UTC(2000, 0, 1, Number(h), Number(mi), Number(s)));
|
|
201
|
+
date.setUTCFullYear(parseYear(y), Number(mo) - 1, Number(d));
|
|
202
|
+
if (date.getUTCMonth() !== Number(mo) - 1 || date.getUTCDate() !== Number(d))
|
|
203
|
+
throw new RangeError(`Not an ISO instant: ${text}`);
|
|
204
|
+
const sec = date.getTime() / 1000;
|
|
205
|
+
return new Instant(sec, frac === undefined ? 0 : Number(frac.padEnd(9, "0")));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The instant now (millisecond precision). */
|
|
209
|
+
static now() { return Instant.fromDate(new Date()); }
|
|
210
|
+
|
|
211
|
+
/** A Date (truncated to milliseconds). */
|
|
212
|
+
toDate() { return new Date(this.epochSecond * 1000 + Math.floor(this.nano / 1000000)); }
|
|
213
|
+
|
|
214
|
+
/** ISO-8601 UTC, as Java's Instant writes it: a fraction of 3, 6 or 9 digits, or none. */
|
|
215
|
+
toString() {
|
|
216
|
+
const d = new Date(this.epochSecond * 1000);
|
|
217
|
+
let frac = "";
|
|
218
|
+
if (this.nano !== 0) {
|
|
219
|
+
const n = pad(this.nano, 9);
|
|
220
|
+
frac = "." + (this.nano % 1000000 === 0 ? n.slice(0, 3) : this.nano % 1000 === 0 ? n.slice(0, 6) : n);
|
|
221
|
+
}
|
|
222
|
+
return `${yearText(d.getUTCFullYear())}-${pad(d.getUTCMonth() + 1, 2)}-${pad(d.getUTCDate(), 2)}T` +
|
|
223
|
+
`${pad(d.getUTCHours(), 2)}:${pad(d.getUTCMinutes(), 2)}:${pad(d.getUTCSeconds(), 2)}${frac}Z`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Whether other is the same instant. */
|
|
227
|
+
equals(other) {
|
|
228
|
+
return other instanceof Instant && other.epochSecond === this.epochSecond && other.nano === this.nano;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const ISO_DATE = /^([+-]\d{4,}|\d{4})-(\d{2})-(\d{2})$/;
|
|
233
|
+
|
|
234
|
+
/** A date without a time or zone (java.time.LocalDate). */
|
|
235
|
+
export class LocalDate {
|
|
236
|
+
constructor(year, month, day) {
|
|
237
|
+
const d = new Date(0);
|
|
238
|
+
d.setUTCFullYear(year, month - 1, day);
|
|
239
|
+
if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day)
|
|
240
|
+
throw new RangeError(`Not a date: ${year}-${month}-${day}`);
|
|
241
|
+
this.year = year;
|
|
242
|
+
this.month = month;
|
|
243
|
+
this.day = day;
|
|
244
|
+
Object.freeze(this);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The date of ISO text ("2026-10-09", "+10000-01-01"). */
|
|
248
|
+
static parse(text) {
|
|
249
|
+
const m = ISO_DATE.exec(text);
|
|
250
|
+
if (m === null) throw new RangeError(`Not an ISO date: ${text}`);
|
|
251
|
+
return new LocalDate(parseYear(m[1]), Number(m[2]), Number(m[3]));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
toString() { return `${yearText(this.year)}-${pad(this.month, 2)}-${pad(this.day, 2)}`; }
|
|
255
|
+
|
|
256
|
+
equals(other) {
|
|
257
|
+
return other instanceof LocalDate && other.year === this.year && other.month === this.month && other.day === this.day;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A typed-JSON tag this client doesn't know, kept as it came: { tag: "$oid", rep }. */
|
|
262
|
+
export class Tagged {
|
|
263
|
+
constructor(tag, rep) { this.tag = tag; this.rep = rep; Object.freeze(this); }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Shorthands. */
|
|
267
|
+
export const uuid = (text) => Uuid.of(text);
|
|
268
|
+
export const kw = (text) => Keyword.of(text);
|
|
269
|
+
export const decimal = (text) => new Decimal(text);
|
|
270
|
+
export const instant = (x) => (x instanceof Date ? Instant.fromDate(x) : Instant.parse(x));
|
|
271
|
+
export const localDate = (text) => LocalDate.parse(text);
|
|
272
|
+
export const double = (v) => new Double(v);
|
|
273
|
+
export const list = (...items) => List.from(items);
|
|
274
|
+
|
|
275
|
+
// ------------------------------------------------------------------ Typed JSON
|
|
276
|
+
// Typed JSON (docs/json.md): stratus's encoding, MongoDB Extended JSON v2's tags where they
|
|
277
|
+
// mean the same and stratus's own where it has none. encode/decode work on JSON text;
|
|
278
|
+
// toJSONValue/fromJSONValue on the parsed tree.
|
|
279
|
+
//
|
|
280
|
+
// JavaScript to stratus:
|
|
281
|
+
// string, boolean, null themselves
|
|
282
|
+
// number an integer within ±2^53 is a long; anything else a double
|
|
283
|
+
// (NaN, the infinities, -0 and integers past 2^53 as
|
|
284
|
+
// $numberDouble). An integral double needs Double(1).
|
|
285
|
+
// bigint a long ($numberLong past ±2^53), past 64 bits $bigint
|
|
286
|
+
// Uuid Keyword Sym Char Decimal BigInteger Ratio Instant LocalDate Double List Tagged
|
|
287
|
+
// their tags (types.js); a Date is an Instant (ms)
|
|
288
|
+
// Uint8Array $binary (subtype 00)
|
|
289
|
+
// Array a vector; a List a list
|
|
290
|
+
// Set $set
|
|
291
|
+
// plain object a map: its keys are keywords ("resource/id"); undefined
|
|
292
|
+
// values are left out
|
|
293
|
+
// Map $map: its keys are values (a string key stays a string)
|
|
294
|
+
// stratus to JavaScript: the same, backwards; a long past ±2^53 is a bigint, an unknown tag a
|
|
295
|
+
// Tagged (or an error, with { strict: true }).
|
|
296
|
+
|
|
297
|
+
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
298
|
+
const INT64_MIN = -(2n ** 63n);
|
|
299
|
+
const INT64_MAX = 2n ** 63n - 1n;
|
|
300
|
+
|
|
301
|
+
function tag(t, rep) { return { [t]: rep }; }
|
|
302
|
+
|
|
303
|
+
function base64(bytes) {
|
|
304
|
+
let s = "";
|
|
305
|
+
for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
306
|
+
return btoa(s);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function unbase64(text) {
|
|
310
|
+
const s = atob(text);
|
|
311
|
+
const b = new Uint8Array(s.length);
|
|
312
|
+
for (let i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
|
|
313
|
+
return b;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function doubleText(n) {
|
|
317
|
+
if (Number.isNaN(n)) return "NaN";
|
|
318
|
+
if (n === Infinity) return "Infinity";
|
|
319
|
+
if (n === -Infinity) return "-Infinity";
|
|
320
|
+
if (Object.is(n, -0)) return "-0.0";
|
|
321
|
+
return String(n);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isPlainObject(x) {
|
|
325
|
+
const p = Object.getPrototypeOf(x);
|
|
326
|
+
return p === Object.prototype || p === null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function encodeNumber(n) {
|
|
330
|
+
if (Number.isSafeInteger(n) && !Object.is(n, -0)) return n;
|
|
331
|
+
if (Number.isFinite(n) && !Number.isInteger(n)) return n;
|
|
332
|
+
return tag("$numberDouble", doubleText(n));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function encodeBigInt(b) {
|
|
336
|
+
if (b >= -MAX_SAFE && b <= MAX_SAFE) return Number(b);
|
|
337
|
+
if (b >= INT64_MIN && b <= INT64_MAX) return tag("$numberLong", b.toString());
|
|
338
|
+
return tag("$bigint", b.toString());
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The JSON tree of value x (JSON.stringify writes it). */
|
|
342
|
+
export function toJSONValue(x) {
|
|
343
|
+
switch (typeof x) {
|
|
344
|
+
case "string":
|
|
345
|
+
case "boolean":
|
|
346
|
+
return x;
|
|
347
|
+
case "number":
|
|
348
|
+
return encodeNumber(x);
|
|
349
|
+
case "bigint":
|
|
350
|
+
return encodeBigInt(x);
|
|
351
|
+
case "undefined":
|
|
352
|
+
throw new TypeError("undefined has no typed-JSON encoding (use null)");
|
|
353
|
+
case "object":
|
|
354
|
+
break;
|
|
355
|
+
default:
|
|
356
|
+
throw new TypeError(`No typed-JSON encoding for a ${typeof x}`);
|
|
357
|
+
}
|
|
358
|
+
if (x === null) return null;
|
|
359
|
+
if (x instanceof Uuid) return tag("$uuid", x.text);
|
|
360
|
+
if (x instanceof Keyword) return tag("$kw", x.fullName);
|
|
361
|
+
if (x instanceof Instant) return tag("$inst", x.toString());
|
|
362
|
+
if (x instanceof Date) return tag("$inst", Instant.fromDate(x).toString());
|
|
363
|
+
if (x instanceof LocalDate) return tag("$localDate", x.toString());
|
|
364
|
+
if (x instanceof Decimal) return tag("$numberDecimal", x.text);
|
|
365
|
+
if (x instanceof Double) {
|
|
366
|
+
return Number.isFinite(x.value) && !Number.isInteger(x.value) ? x.value : tag("$numberDouble", doubleText(x.value));
|
|
367
|
+
}
|
|
368
|
+
if (x instanceof BigInteger) return tag("$bigint", x.value.toString());
|
|
369
|
+
if (x instanceof Ratio) return tag("$ratio", x.toString());
|
|
370
|
+
if (x instanceof Sym) return tag("$sym", x.fullName);
|
|
371
|
+
if (x instanceof Char) return tag("$char", x.value);
|
|
372
|
+
if (x instanceof Tagged) return tag(x.tag, toJSONValue(x.rep));
|
|
373
|
+
if (x instanceof Uint8Array) return tag("$binary", { base64: base64(x), subType: "00" });
|
|
374
|
+
if (x instanceof List) return tag("$list", Array.from(x, toJSONValue));
|
|
375
|
+
if (Array.isArray(x)) return x.map(toJSONValue);
|
|
376
|
+
if (x instanceof Set) return tag("$set", Array.from(x, toJSONValue));
|
|
377
|
+
if (x instanceof Map) return tag("$map", Array.from(x, ([k, v]) => [toJSONValue(k), toJSONValue(v)]));
|
|
378
|
+
if (isPlainObject(x)) {
|
|
379
|
+
const keys = Object.keys(x).filter((k) => x[k] !== undefined);
|
|
380
|
+
// a lone key starting with $ would read as a tag: such a map goes as $map
|
|
381
|
+
if (keys.length === 1 && keys[0].startsWith("$"))
|
|
382
|
+
return tag("$map", [[tag("$kw", keys[0]), toJSONValue(x[keys[0]])]]);
|
|
383
|
+
const out = {};
|
|
384
|
+
for (const k of keys) out[k] = toJSONValue(x[k]);
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
throw new TypeError(`No typed-JSON encoding for ${x.constructor?.name ?? "this object"}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** x as typed-JSON text. */
|
|
391
|
+
export function encode(x) {
|
|
392
|
+
return JSON.stringify(toJSONValue(x));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function bad(msg) { return new TypeError(msg); }
|
|
396
|
+
|
|
397
|
+
function text(t, rep) {
|
|
398
|
+
if (typeof rep !== "string") throw bad(`${t} takes a string`);
|
|
399
|
+
return rep;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function array(t, rep) {
|
|
403
|
+
if (!Array.isArray(rep)) throw bad(`${t} takes an array`);
|
|
404
|
+
return rep;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function parseLong(t, s) {
|
|
408
|
+
if (!/^[+-]?\d+$/.test(s)) throw bad(`${t} isn't an integer: ${s}`);
|
|
409
|
+
const b = BigInt(s);
|
|
410
|
+
if (b < INT64_MIN || b > INT64_MAX) throw bad(`${t} isn't a 64-bit integer: ${s}`);
|
|
411
|
+
return b >= -MAX_SAFE && b <= MAX_SAFE ? Number(b) : b;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function parseDouble(s) {
|
|
415
|
+
switch (s) {
|
|
416
|
+
case "NaN": return NaN;
|
|
417
|
+
case "Infinity": return Infinity;
|
|
418
|
+
case "-Infinity": return -Infinity;
|
|
419
|
+
default: {
|
|
420
|
+
if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) throw bad(`$numberDouble isn't a number: ${s}`);
|
|
421
|
+
return Number(s);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function uuidOfBytes(b) {
|
|
427
|
+
if (b.length !== 16) throw bad("A $binary UUID (subtype 04) has 16 bytes");
|
|
428
|
+
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
429
|
+
return Uuid.of(`${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function decodeTag(t, rep, opts) {
|
|
433
|
+
switch (t) {
|
|
434
|
+
case "$numberLong": return parseLong(t, text(t, rep));
|
|
435
|
+
case "$numberInt": return parseLong(t, text(t, rep));
|
|
436
|
+
case "$numberDouble": return parseDouble(text(t, rep));
|
|
437
|
+
case "$numberDecimal": return new Decimal(text(t, rep));
|
|
438
|
+
case "$bigint": {
|
|
439
|
+
const s = text(t, rep);
|
|
440
|
+
if (!/^[+-]?\d+$/.test(s)) throw bad(`$bigint isn't an integer: ${s}`);
|
|
441
|
+
return new BigInteger(s);
|
|
442
|
+
}
|
|
443
|
+
case "$ratio": {
|
|
444
|
+
const m = /^([+-]?\d+)\/(\d+)$/.exec(text(t, rep));
|
|
445
|
+
if (m === null) throw bad(`$ratio is n/d: ${rep}`);
|
|
446
|
+
return new Ratio(m[1], m[2]);
|
|
447
|
+
}
|
|
448
|
+
case "$kw": return Keyword.of(text(t, rep));
|
|
449
|
+
case "$sym": return new Sym(text(t, rep));
|
|
450
|
+
case "$char": return new Char(text(t, rep));
|
|
451
|
+
case "$uuid": return Uuid.of(text(t, rep));
|
|
452
|
+
case "$inst": return Instant.parse(text(t, rep));
|
|
453
|
+
case "$date": {
|
|
454
|
+
if (typeof rep === "string") return Instant.fromDate(new Date(rep));
|
|
455
|
+
if (rep !== null && typeof rep === "object" && typeof rep.$numberLong === "string") {
|
|
456
|
+
const ms = Number(rep.$numberLong);
|
|
457
|
+
const s = Math.floor(ms / 1000);
|
|
458
|
+
return new Instant(s, (ms - s * 1000) * 1000000);
|
|
459
|
+
}
|
|
460
|
+
throw bad("$date takes an ISO string or {$numberLong: millis}");
|
|
461
|
+
}
|
|
462
|
+
case "$localDate": return LocalDate.parse(text(t, rep));
|
|
463
|
+
case "$binary": {
|
|
464
|
+
if (rep === null || typeof rep !== "object") throw bad("$binary takes {base64, subType}");
|
|
465
|
+
const b = unbase64(text("$binary's base64", rep.base64));
|
|
466
|
+
const sub = text("$binary's subType", rep.subType);
|
|
467
|
+
return sub === "04" || sub === "4" ? uuidOfBytes(b) : b;
|
|
468
|
+
}
|
|
469
|
+
case "$set": return new Set(array(t, rep).map((x) => fromJSONValue(x, opts)));
|
|
470
|
+
case "$list": return List.from(array(t, rep).map((x) => fromJSONValue(x, opts)));
|
|
471
|
+
case "$map": {
|
|
472
|
+
const m = new Map();
|
|
473
|
+
for (const p of array(t, rep)) {
|
|
474
|
+
if (!Array.isArray(p) || p.length !== 2) throw bad("A $map entry is [k, v]");
|
|
475
|
+
m.set(fromJSONValue(p[0], opts), fromJSONValue(p[1], opts));
|
|
476
|
+
}
|
|
477
|
+
// keys all keywords (a lone $-named key, escaped): a plain object, whose keys are keywords
|
|
478
|
+
if (m.size > 0 && Array.from(m.keys()).every((k) => k instanceof Keyword)) {
|
|
479
|
+
const o = {};
|
|
480
|
+
for (const [k, v] of m) o[k.fullName] = v;
|
|
481
|
+
return o;
|
|
482
|
+
}
|
|
483
|
+
return m;
|
|
484
|
+
}
|
|
485
|
+
default:
|
|
486
|
+
if (opts?.strict) throw bad(`Unknown typed-JSON tag ${t}`);
|
|
487
|
+
return new Tagged(t, fromJSONValue(rep, opts));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** The value of a parsed JSON tree. opts.strict: an unknown tag throws. */
|
|
492
|
+
export function fromJSONValue(x, opts) {
|
|
493
|
+
if (Array.isArray(x)) return x.map((y) => fromJSONValue(y, opts));
|
|
494
|
+
if (x !== null && typeof x === "object") {
|
|
495
|
+
const keys = Object.keys(x);
|
|
496
|
+
if (keys.length === 1 && keys[0].startsWith("$")) return decodeTag(keys[0], x[keys[0]], opts);
|
|
497
|
+
const out = {};
|
|
498
|
+
for (const k of keys) out[k] = fromJSONValue(x[k], opts);
|
|
499
|
+
return out;
|
|
500
|
+
}
|
|
501
|
+
return x;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The value of typed-JSON text. opts.strict: an unknown tag throws. */
|
|
505
|
+
export function decode(text, opts) {
|
|
506
|
+
return fromJSONValue(JSON.parse(text), opts);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ------------------------------------------------------------------ The client
|
|
510
|
+
|
|
511
|
+
/** A request that failed: its HTTP status, the error's kind ("invalid-query") and message. */
|
|
512
|
+
export class StratusError extends Error {
|
|
513
|
+
constructor(status, error, message, body) {
|
|
514
|
+
super(`HTTP ${status}${error ? ` ${error}` : ""}: ${message ?? ""}`);
|
|
515
|
+
this.name = "StratusError";
|
|
516
|
+
this.status = status;
|
|
517
|
+
this.error = error ?? null;
|
|
518
|
+
this.body = body;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
523
|
+
|
|
524
|
+
function trimSlash(u) { return u.endsWith("/") ? u.slice(0, -1) : u; }
|
|
525
|
+
|
|
526
|
+
function queryString(params) {
|
|
527
|
+
const p = new URLSearchParams();
|
|
528
|
+
for (const [k, v] of Object.entries(params ?? {})) if (v !== undefined && v !== null) p.set(k, String(v));
|
|
529
|
+
const s = p.toString();
|
|
530
|
+
return s === "" ? "" : `?${s}`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function attName(att) { return att instanceof Keyword ? att.fullName : String(att).replace(/^:/, ""); }
|
|
534
|
+
|
|
535
|
+
/** A client of the stratus service at opts.url (see the module's comment for opts). */
|
|
536
|
+
export function connect(opts) {
|
|
537
|
+
return new Client(opts);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export class Client {
|
|
541
|
+
/**
|
|
542
|
+
* @param {object} opts
|
|
543
|
+
* @param {string} opts.url the service's base URL (hosted), or the catalog's URL (function-urls)
|
|
544
|
+
* @param {string} [opts.apiKey] sent as "Authorization: Bearer <key>"
|
|
545
|
+
* @param {"hosted"|"function-urls"} [opts.addressing]
|
|
546
|
+
* @param {string} [opts.caller] x-stratus-caller, for a local service
|
|
547
|
+
* @param {number} [opts.retries] attempts after a 429 (default 10)
|
|
548
|
+
* @param {number} [opts.resolveMs] function-urls: how long a database's URL is cached (5 minutes)
|
|
549
|
+
* @param {typeof fetch} [opts.fetch] default globalThis.fetch
|
|
550
|
+
*/
|
|
551
|
+
constructor(opts) {
|
|
552
|
+
if (!opts?.url) throw new TypeError("connect needs { url }");
|
|
553
|
+
this.url = trimSlash(opts.url);
|
|
554
|
+
this.addressing = opts.addressing ?? "hosted";
|
|
555
|
+
if (!["hosted", "function-urls"].includes(this.addressing)) throw new TypeError(`addressing: ${this.addressing}`);
|
|
556
|
+
this.apiKey = opts.apiKey;
|
|
557
|
+
this.caller = opts.caller;
|
|
558
|
+
this.retries = opts.retries ?? 10;
|
|
559
|
+
this.resolveMs = opts.resolveMs ?? 300000;
|
|
560
|
+
this.fetch = opts.fetch ?? globalThis.fetch;
|
|
561
|
+
this.urls = { at: 0, byId: new Map() };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// -- requests
|
|
565
|
+
|
|
566
|
+
async send(url, method, body) {
|
|
567
|
+
const headers = { accept: "application/json" };
|
|
568
|
+
// Node's fetch sends ours (so old copies can be seen); a browser sends its own
|
|
569
|
+
if (IN_NODE) headers["user-agent"] = USER_AGENT;
|
|
570
|
+
if (body !== undefined) headers["content-type"] = "application/json";
|
|
571
|
+
if (this.apiKey) headers.authorization = `Bearer ${this.apiKey}`;
|
|
572
|
+
if (this.caller) headers["x-stratus-caller"] = this.caller;
|
|
573
|
+
for (let attempt = 0; ; attempt++) {
|
|
574
|
+
const r = await this.fetch(url, { method, headers, body: body === undefined ? undefined : encode(body) });
|
|
575
|
+
const textBody = await r.text();
|
|
576
|
+
if (r.status === 429 && attempt < this.retries) {
|
|
577
|
+
// full jitter: up to 50 ms x 2^attempt, at most 5 s
|
|
578
|
+
await sleep(Math.random() * Math.min(5000, 50 * 2 ** attempt));
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const ct = r.headers.get("content-type") ?? "";
|
|
582
|
+
let value = textBody;
|
|
583
|
+
if (ct.startsWith("application/json") && textBody !== "") {
|
|
584
|
+
try { value = decode(textBody); } catch { value = textBody; }
|
|
585
|
+
}
|
|
586
|
+
return { status: r.status, body: value };
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
static ok(r) {
|
|
591
|
+
if (r.status >= 200 && r.status < 300) return r.body;
|
|
592
|
+
const b = r.body;
|
|
593
|
+
const isObj = b !== null && typeof b === "object";
|
|
594
|
+
throw new StratusError(r.status, isObj ? b.error : null, isObj ? b.message : String(b ?? ""), b);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
catalogUrl(path) {
|
|
598
|
+
return this.addressing === "hosted" ? `${this.url}/v1${path}` : `${this.url}${path}`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** method path body to the catalog: { status, body }. */
|
|
602
|
+
callCatalog(method, path, body) {
|
|
603
|
+
return this.send(this.catalogUrl(path), method, body);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async resolve() {
|
|
607
|
+
const recs = Client.ok(await this.callCatalog("GET", "/dbs"));
|
|
608
|
+
const byId = new Map();
|
|
609
|
+
for (const r of recs) if (r.url) byId.set(String(r.id), r.url);
|
|
610
|
+
this.urls = { at: Date.now(), byId };
|
|
611
|
+
return byId;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async dbBase(id) {
|
|
615
|
+
const key = String(id);
|
|
616
|
+
if (this.addressing === "hosted") return `${this.url}/v1/dbs/${encodeURIComponent(key)}`;
|
|
617
|
+
const { at, byId } = this.urls;
|
|
618
|
+
if (byId.has(key) && Date.now() - at < this.resolveMs) return trimSlash(byId.get(key));
|
|
619
|
+
const u = (await this.resolve()).get(key);
|
|
620
|
+
return u === undefined ? null : trimSlash(u);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** method path body to database id: { status, body }. */
|
|
624
|
+
async callDb(id, method, path, body) {
|
|
625
|
+
const base = await this.dbBase(id);
|
|
626
|
+
if (base === null) return { status: 404, body: { error: "no-database", message: `No database ${id}` } };
|
|
627
|
+
const r = await this.send(base + path, method, body);
|
|
628
|
+
if (this.addressing === "function-urls" && (r.status === 403 || r.status === 404)) {
|
|
629
|
+
const again = (await this.resolve()).get(String(id));
|
|
630
|
+
if (again !== undefined && trimSlash(again) !== base) return this.send(trimSlash(again) + path, method, body);
|
|
631
|
+
}
|
|
632
|
+
return r;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// -- the catalog
|
|
636
|
+
|
|
637
|
+
/** The live databases' records ({ deleted: true } for deleted ones too). */
|
|
638
|
+
async listDbs(opts) {
|
|
639
|
+
return Client.ok(await this.callCatalog("GET", `/dbs${queryString({ deleted: opts?.deleted ? "true" : undefined })}`));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** The live database named name's record. */
|
|
643
|
+
async dbByName(name) {
|
|
644
|
+
return Client.ok(await this.callCatalog("GET", `/dbs${queryString({ name })}`));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Database id's record. */
|
|
648
|
+
async dbRecord(id) {
|
|
649
|
+
return Client.ok(await this.callCatalog("GET", `/dbs/${encodeURIComponent(String(id))}`));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Creates a database: { id, url }. primaryKey is fixed for its life ("order/id"). */
|
|
653
|
+
async createDb({ name, primaryKey }) {
|
|
654
|
+
const body = { "primary-key": Keyword.of(attName(primaryKey)) };
|
|
655
|
+
if (name !== undefined) body.name = name;
|
|
656
|
+
return Client.ok(await this.callCatalog("POST", "/dbs", body));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async renameDb(id, name) {
|
|
660
|
+
return Client.ok(await this.callCatalog("PATCH", `/dbs/${encodeURIComponent(String(id))}`, { name }));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async deleteDb(id) {
|
|
664
|
+
return Client.ok(await this.callCatalog("DELETE", `/dbs/${encodeURIComponent(String(id))}`));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// -- keys (hosted: an account's keys, managed with a key whose scope is every database)
|
|
668
|
+
|
|
669
|
+
/** The account's keys' records (no secrets). */
|
|
670
|
+
async listKeys() {
|
|
671
|
+
return Client.ok(await this.callCatalog("GET", "/keys"));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** A new key: { key (shown once), record }. scope: database ids it may reach (default all). */
|
|
675
|
+
async createKey({ name, scope } = {}) {
|
|
676
|
+
const body = {};
|
|
677
|
+
if (name !== undefined) body.name = name;
|
|
678
|
+
if (scope !== undefined) body.scope = scope.map(String);
|
|
679
|
+
return Client.ok(await this.callCatalog("POST", "/keys", body));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Revokes key keyId: its record. */
|
|
683
|
+
async revokeKey(keyId) {
|
|
684
|
+
return Client.ok(await this.callCatalog("DELETE", `/keys/${encodeURIComponent(keyId)}`));
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// -- a database
|
|
688
|
+
|
|
689
|
+
/** The database's state: { id, "primary-key", t, "checkpoint-t", indexes, "index-errors", status }. */
|
|
690
|
+
async status(id) {
|
|
691
|
+
return Client.ok(await this.callDb(id, "GET", this.addressing === "hosted" ? "/status" : "/"));
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Transacts: { t, pks, duplicate? }. tx: rows (each with its primary key: squuid()),
|
|
696
|
+
* retract: rows of attributes to remove, delete: primary keys, txId: makes a retry safe,
|
|
697
|
+
* match: rows whose attributes must hold those values now (else a 409 "conflict").
|
|
698
|
+
*/
|
|
699
|
+
async transact(id, { tx, retract, delete: del, txId, match }) {
|
|
700
|
+
const body = {};
|
|
701
|
+
if (tx !== undefined) body.tx = tx;
|
|
702
|
+
if (retract !== undefined) body.retract = retract;
|
|
703
|
+
if (del !== undefined) body.delete = del;
|
|
704
|
+
if (txId !== undefined) body["tx-id"] = txId;
|
|
705
|
+
if (match !== undefined) body.match = match;
|
|
706
|
+
return Client.ok(await this.callDb(id, "POST", "/transact", body));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** One page of query q (docs/query.md, "JSON"): { rows, next, t }. */
|
|
710
|
+
async query(id, q = {}) {
|
|
711
|
+
return Client.ok(await this.callDb(id, "POST", "/query", q));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Every row of query q, page after page. */
|
|
715
|
+
async *queryAll(id, q = {}) {
|
|
716
|
+
let after = q.after;
|
|
717
|
+
for (;;) {
|
|
718
|
+
const page = await this.query(id, after === undefined ? q : { ...q, after });
|
|
719
|
+
yield* page.rows;
|
|
720
|
+
if (page.next === null || page.next === undefined) return;
|
|
721
|
+
after = page.next;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** The rows of primary keys pks, in order (null where there is none). */
|
|
726
|
+
async pull(id, pks) {
|
|
727
|
+
return (Client.ok(await this.callDb(id, "POST", "/pull", { pks }))).rows;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Requests a value index on att: { att, state } ("building" until a checkpoint builds it). */
|
|
731
|
+
async requestIndex(id, att) {
|
|
732
|
+
return Client.ok(await this.callDb(id, "POST", "/indexes", { att: attName(att) }));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async dropIndex(id, att) {
|
|
736
|
+
return Client.ok(await this.callDb(id, "DELETE", `/indexes/${attName(att).split("/").map(encodeURIComponent).join("/")}`));
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Waits until att's index is ready (rejects if it failed, or after timeoutMs). */
|
|
740
|
+
async awaitIndex(id, att, { timeoutMs = 60000, intervalMs = 200 } = {}) {
|
|
741
|
+
const name = attName(att);
|
|
742
|
+
const deadline = Date.now() + timeoutMs;
|
|
743
|
+
for (;;) {
|
|
744
|
+
const s = await this.status(id);
|
|
745
|
+
const state = s.indexes?.[name];
|
|
746
|
+
if (state === "ready") return s;
|
|
747
|
+
if (state === "failed") throw new StratusError(409, "index-failed", s["index-errors"]?.[name] ?? `${name} failed`, s);
|
|
748
|
+
if (Date.now() > deadline) throw new StratusError(408, "timeout", `${name} isn't ready (${state})`, s);
|
|
749
|
+
await sleep(intervalMs);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Forks database id (hosted): a new database, this one as of t (default its latest), recorded
|
|
755
|
+
* in both logs. The fork's record ({ id, "fork-of": { db, t }, ... }).
|
|
756
|
+
*/
|
|
757
|
+
async fork(id, { t, name } = {}) {
|
|
758
|
+
const body = {};
|
|
759
|
+
if (t !== undefined) body.t = t;
|
|
760
|
+
if (name !== undefined) body.name = name;
|
|
761
|
+
return Client.ok(await this.callDb(id, "POST", "/fork", body));
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Excises rows (primary keys) and attributes ({ pk, att }) - erasure: gone from the current
|
|
766
|
+
* state, its indexes and history (and, hosted, the database's forks): { t, rows, attributes }.
|
|
767
|
+
*/
|
|
768
|
+
async excise(id, { rows, attributes } = {}) {
|
|
769
|
+
const body = {};
|
|
770
|
+
if (rows !== undefined) body.rows = rows;
|
|
771
|
+
if (attributes !== undefined) body.attributes = attributes.map(({ pk, att }) => ({ pk, att: attName(att) }));
|
|
772
|
+
return Client.ok(await this.callDb(id, "POST", "/excise", body));
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** One page of the database's history from t: { entries, next }. */
|
|
776
|
+
async history(id, { from = 1, limit } = {}) {
|
|
777
|
+
return Client.ok(await this.callDb(id, "GET", `/history${queryString({ from, limit })}`));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Every history entry from t on, page after page. */
|
|
781
|
+
async *historyAll(id, { from = 1, limit } = {}) {
|
|
782
|
+
let t = from;
|
|
783
|
+
for (;;) {
|
|
784
|
+
const page = await this.history(id, { from: t, limit });
|
|
785
|
+
yield* page.entries;
|
|
786
|
+
if (page.next === null || page.next === undefined) return;
|
|
787
|
+
t = page.next;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Sequential UUIDs for primary keys: every transacted row carries its key (so a retried
|
|
793
|
+
// transaction lands on the same rows), and squuids keep new rows at the right of the
|
|
794
|
+
// database's tree. The server's layout (RFC 9562 version 7): 48 bits of Unix ms, the version,
|
|
795
|
+
// a 12-bit sequence, the variant, 62 random bits. Later ones in this process sort after
|
|
796
|
+
// earlier ones (the server's UUID order); across processes, to the millisecond.
|
|
797
|
+
|
|
798
|
+
let squuidMs = 0;
|
|
799
|
+
let squuidSeq = 0;
|
|
800
|
+
|
|
801
|
+
const hex = (n, width) => n.toString(16).padStart(width, "0");
|
|
802
|
+
|
|
803
|
+
/** A sequential UUID. */
|
|
804
|
+
export function squuid() {
|
|
805
|
+
const now = Date.now();
|
|
806
|
+
if (now > squuidMs) {
|
|
807
|
+
squuidMs = now;
|
|
808
|
+
squuidSeq = 0;
|
|
809
|
+
} else if (squuidSeq < 0xfff) {
|
|
810
|
+
squuidSeq += 1;
|
|
811
|
+
} else {
|
|
812
|
+
squuidMs += 1;
|
|
813
|
+
squuidSeq = 0;
|
|
814
|
+
}
|
|
815
|
+
const r = new Uint8Array(8);
|
|
816
|
+
globalThis.crypto.getRandomValues(r);
|
|
817
|
+
r[0] = 0x80 | (r[0] & 0x3f);
|
|
818
|
+
const rh = Array.from(r, (b) => hex(b, 2)).join("");
|
|
819
|
+
const mh = hex(squuidMs, 12);
|
|
820
|
+
return Uuid.of(`${mh.slice(0, 8)}-${mh.slice(8, 12)}-7${hex(squuidSeq, 3)}-${rh.slice(0, 4)}-${rh.slice(4, 16)}`);
|
|
821
|
+
}
|