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.
Files changed (3) hide show
  1. package/README.md +75 -2
  2. package/package.json +8 -4
  3. package/stratus-db.js +821 -0
package/README.md CHANGED
@@ -1,3 +1,76 @@
1
- # Temporary Holding Version
1
+ # stratus-db
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }