terrascale 0.3.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +169 -115
- package/package.json +11 -16
- package/sdk-v1-route-manifest.json +2330 -0
- package/src/database-view.js +2 -1
- package/src/database.js +2 -420
- package/src/index.js +58 -75
- package/src/local/index.js +5 -3
- package/src/local/legacy-client.js +411 -0
- package/src/postgres.js +3 -1
- package/src/tanstack/index.js +2 -1
- package/src/v1/decode.js +110 -0
- package/src/v1/errors.js +185 -0
- package/src/v1/filter.js +230 -0
- package/src/v1/ids.js +118 -0
- package/src/v1/json.js +335 -0
- package/src/v1/routes.js +57 -0
- package/src/v1/shapes.js +145 -0
- package/src/v1/terrabase-v1.js +722 -0
- package/src/v1/transport.js +324 -0
- package/types/database-view.d.ts +3 -2
- package/types/database.d.ts +2 -98
- package/types/index.d.ts +42 -51
- package/types/local/index.d.ts +6 -4
- package/types/local/legacy-client.d.ts +94 -0
- package/types/local/test-environment.d.ts +1 -1
- package/types/postgres.d.ts +1 -0
- package/types/tanstack/index.d.ts +3 -2
- package/types/v1/decode.d.ts +105 -0
- package/types/v1/errors.d.ts +107 -0
- package/types/v1/filter.d.ts +148 -0
- package/types/v1/ids.d.ts +44 -0
- package/types/v1/json.d.ts +92 -0
- package/types/v1/routes.d.ts +26 -0
- package/types/v1/shapes.d.ts +78 -0
- package/types/v1/terrabase-v1.d.ts +374 -0
- package/types/v1/transport.d.ts +117 -0
- package/sdk-current-contract.json +0 -27
- package/sdk-route-manifest.json +0 -67
- package/src/discovery.js +0 -374
- package/types/discovery.d.ts +0 -114
package/src/v1/json.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Lossless JSON for the TerraBase v1 Documents API.
|
|
2
|
+
//
|
|
3
|
+
// TerraBase numbers are exact decimals with up to 128 significant digits
|
|
4
|
+
// (api-v1.md). Responses are parsed with JSON.parse source text access, so a
|
|
5
|
+
// number is returned as a JavaScript number only when that number denotes the
|
|
6
|
+
// same decimal value as the wire text; every other number becomes an
|
|
7
|
+
// ExactDecimal holding the original text. Requests are encoded with
|
|
8
|
+
// JSON.rawJSON, so ExactDecimal and bigint values reach the server unchanged.
|
|
9
|
+
|
|
10
|
+
const decimalGrammar = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u;
|
|
11
|
+
const maximumIntegerExponent = 4096n;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {object} CanonicalDecimal
|
|
15
|
+
* @property {boolean} negative
|
|
16
|
+
* @property {string} digits Significant digits without leading or trailing zeros; empty for zero.
|
|
17
|
+
* @property {bigint} exponent The value is digits × 10^exponent.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} text
|
|
22
|
+
* @returns {CanonicalDecimal | undefined}
|
|
23
|
+
*/
|
|
24
|
+
function canonicalDecimal(text) {
|
|
25
|
+
const match = /^(-)?(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/u.exec(text);
|
|
26
|
+
if (match === null) return undefined;
|
|
27
|
+
const fraction = match[3] ?? "";
|
|
28
|
+
let digits = `${match[2]}${fraction}`.replace(/^0+/u, "");
|
|
29
|
+
let exponent = BigInt(match[4] ?? "0") - BigInt(fraction.length);
|
|
30
|
+
if (digits === "") return { negative: false, digits: "", exponent: 0n };
|
|
31
|
+
const trimmed = digits.replace(/0+$/u, "");
|
|
32
|
+
exponent += BigInt(digits.length - trimmed.length);
|
|
33
|
+
digits = trimmed;
|
|
34
|
+
return { negative: match[1] === "-", digits, exponent };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} left
|
|
39
|
+
* @param {string} right
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
function sameDecimal(left, right) {
|
|
43
|
+
const a = canonicalDecimal(left);
|
|
44
|
+
const b = canonicalDecimal(right);
|
|
45
|
+
return (
|
|
46
|
+
a !== undefined &&
|
|
47
|
+
b !== undefined &&
|
|
48
|
+
a.negative === b.negative &&
|
|
49
|
+
a.digits === b.digits &&
|
|
50
|
+
a.exponent === b.exponent
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @returns {(text: string) => unknown} */
|
|
55
|
+
function rawJsonFactory() {
|
|
56
|
+
const factory = /** @type {{ rawJSON?: unknown }} */ (
|
|
57
|
+
/** @type {unknown} */ (JSON)
|
|
58
|
+
).rawJSON;
|
|
59
|
+
if (typeof factory !== "function")
|
|
60
|
+
throw new TypeError(
|
|
61
|
+
"TerraBase v1 requires JSON.rawJSON and JSON.parse source text access (Node.js 22+, current browsers) for exact numbers.",
|
|
62
|
+
);
|
|
63
|
+
return /** @type {(text: string) => unknown} */ (factory);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* An exact decimal number as it travels on the wire. Values that a JavaScript
|
|
68
|
+
* number cannot represent exactly (large integers, long fractions) are
|
|
69
|
+
* returned as ExactDecimal; it serializes back to the identical JSON text.
|
|
70
|
+
*/
|
|
71
|
+
export class ExactDecimal {
|
|
72
|
+
/** @type {string} */
|
|
73
|
+
#text;
|
|
74
|
+
|
|
75
|
+
/** @param {string} text A JSON number literal. */
|
|
76
|
+
constructor(text) {
|
|
77
|
+
if (typeof text !== "string" || !decimalGrammar.test(text))
|
|
78
|
+
throw new TypeError("ExactDecimal requires a JSON number literal.");
|
|
79
|
+
this.#text = text;
|
|
80
|
+
Object.freeze(this);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {ExactDecimal | bigint | number | string} value
|
|
85
|
+
* @returns {ExactDecimal}
|
|
86
|
+
*/
|
|
87
|
+
static from(value) {
|
|
88
|
+
if (value instanceof ExactDecimal) return value;
|
|
89
|
+
if (typeof value === "bigint") return new ExactDecimal(value.toString());
|
|
90
|
+
if (typeof value === "number") {
|
|
91
|
+
if (!Number.isFinite(value))
|
|
92
|
+
throw new TypeError("TerraBase numbers must be finite.");
|
|
93
|
+
return new ExactDecimal(String(value));
|
|
94
|
+
}
|
|
95
|
+
return new ExactDecimal(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @returns {string} The exact JSON number text. */
|
|
99
|
+
toString() {
|
|
100
|
+
return this.#text;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @returns {unknown} A raw JSON value that serializes as the exact text. */
|
|
104
|
+
toJSON() {
|
|
105
|
+
return rawJsonFactory()(this.#text);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @returns {number} The nearest JavaScript number; may lose precision. */
|
|
109
|
+
toNumber() {
|
|
110
|
+
return Number(this.#text);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @returns {boolean} */
|
|
114
|
+
isInteger() {
|
|
115
|
+
return /** @type {CanonicalDecimal} */ (canonicalDecimal(this.#text)).exponent >= 0n;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @returns {bigint} The exact integer value.
|
|
120
|
+
* @throws {RangeError} When the value is not an integer.
|
|
121
|
+
*/
|
|
122
|
+
toBigInt() {
|
|
123
|
+
const value = /** @type {CanonicalDecimal} */ (canonicalDecimal(this.#text));
|
|
124
|
+
if (value.exponent < 0n)
|
|
125
|
+
throw new RangeError(`${this.#text} is not an integer.`);
|
|
126
|
+
if (value.exponent > maximumIntegerExponent)
|
|
127
|
+
throw new RangeError(`${this.#text} is too large to expand.`);
|
|
128
|
+
if (value.digits === "") return 0n;
|
|
129
|
+
const magnitude = BigInt(
|
|
130
|
+
`${value.digits}${"0".repeat(Number(value.exponent))}`,
|
|
131
|
+
);
|
|
132
|
+
return value.negative ? -magnitude : magnitude;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Exact decimal equality: 1, 1.0 and 1e0 are equal.
|
|
137
|
+
* @param {ExactDecimal | bigint | number | string} other
|
|
138
|
+
* @returns {boolean}
|
|
139
|
+
*/
|
|
140
|
+
equals(other) {
|
|
141
|
+
return sameDecimal(this.#text, ExactDecimal.from(other).toString());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @returns {string} */
|
|
145
|
+
[Symbol.for("nodejs.util.inspect.custom")]() {
|
|
146
|
+
return `ExactDecimal(${this.#text})`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A decoded JSON value. Numbers are `number` when exact, else ExactDecimal.
|
|
152
|
+
* @typedef {null | boolean | number | string | ExactDecimal | JsonArray | JsonObject} JsonValue
|
|
153
|
+
*/
|
|
154
|
+
/** @typedef {JsonValue[]} JsonArray */
|
|
155
|
+
/** @typedef {{ [key: string]: JsonValue }} JsonObject */
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A JSON value accepted in requests: bigint and ExactDecimal encode exactly.
|
|
159
|
+
* @typedef {null | boolean | number | bigint | string | ExactDecimal | JsonInputArray | JsonInputObject} JsonInput
|
|
160
|
+
*/
|
|
161
|
+
/** @typedef {readonly JsonInput[]} JsonInputArray */
|
|
162
|
+
/** @typedef {{ readonly [key: string]: JsonInput }} JsonInputObject */
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A JSON.parse reviver that maps every number through `map(source text)`.
|
|
166
|
+
* @param {(source: unknown) => unknown} map
|
|
167
|
+
* @returns {(key: string, value: unknown) => unknown}
|
|
168
|
+
*/
|
|
169
|
+
function sourceReviver(map) {
|
|
170
|
+
/**
|
|
171
|
+
* @param {string} _key
|
|
172
|
+
* @param {unknown} value
|
|
173
|
+
* @param {{ source?: unknown }} [context]
|
|
174
|
+
* @returns {unknown}
|
|
175
|
+
*/
|
|
176
|
+
function revive(_key, value, context) {
|
|
177
|
+
return typeof value === "number" ? map(context?.source) ?? value : value;
|
|
178
|
+
}
|
|
179
|
+
return revive;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let sourceAccess = /** @type {boolean | undefined} */ (undefined);
|
|
183
|
+
|
|
184
|
+
/** @returns {void} */
|
|
185
|
+
function requireSourceAccess() {
|
|
186
|
+
if (sourceAccess === undefined) {
|
|
187
|
+
let seen;
|
|
188
|
+
JSON.parse("1", sourceReviver((source) => {
|
|
189
|
+
seen = source;
|
|
190
|
+
}));
|
|
191
|
+
sourceAccess = seen === "1" && typeof (/** @type {{ rawJSON?: unknown }} */ (/** @type {unknown} */ (JSON)).rawJSON) === "function";
|
|
192
|
+
}
|
|
193
|
+
if (!sourceAccess)
|
|
194
|
+
throw new TypeError(
|
|
195
|
+
"TerraBase v1 requires JSON.rawJSON and JSON.parse source text access (Node.js 22+, current browsers) for exact numbers.",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @param {string} source
|
|
201
|
+
* @returns {number | ExactDecimal}
|
|
202
|
+
*/
|
|
203
|
+
function exactNumber(source) {
|
|
204
|
+
const value = Number(source);
|
|
205
|
+
return Number.isFinite(value) && sameDecimal(source, String(value))
|
|
206
|
+
? value
|
|
207
|
+
: new ExactDecimal(source);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parses JSON text without losing number precision.
|
|
212
|
+
* @param {string} text
|
|
213
|
+
* @returns {JsonValue}
|
|
214
|
+
*/
|
|
215
|
+
export function parseExactJson(text) {
|
|
216
|
+
requireSourceAccess();
|
|
217
|
+
return JSON.parse(text, sourceReviver((source) => {
|
|
218
|
+
if (typeof source !== "string")
|
|
219
|
+
throw new TypeError("JSON number source text is unavailable.");
|
|
220
|
+
return exactNumber(source);
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* C# ExactNumber bounds: leading zeros do not count, trailing zeros do.
|
|
226
|
+
* @param {string} text
|
|
227
|
+
* @param {string} where
|
|
228
|
+
* @returns {void}
|
|
229
|
+
*/
|
|
230
|
+
function requireProducerNumber(text, where) {
|
|
231
|
+
if (!decimalGrammar.test(text)) throw new TypeError(`${where} contains a non-finite number.`);
|
|
232
|
+
const [mantissa = "", rawExponent = "0"] = text.split(/[eE]/u);
|
|
233
|
+
const exponent = BigInt(rawExponent);
|
|
234
|
+
const fraction = mantissa.split(".")[1] ?? "";
|
|
235
|
+
const digits = mantissa.replace(/[-.]/gu, "").replace(/^0+/u, "");
|
|
236
|
+
const magnitude = exponent - BigInt(fraction.length) + BigInt(digits.length) - 1n;
|
|
237
|
+
if (exponent < -1_000_000n || exponent > 1_000_000n || digits.length > 128 ||
|
|
238
|
+
(digits !== "" && (magnitude < -307n || magnitude > 307n)))
|
|
239
|
+
throw new TypeError(`${where} numbers have at most 128 significant digits and a magnitude of zero or 1e-307 to below 1e308.`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Validates a document, merge patch or patch operand against C# RequestJson limits.
|
|
244
|
+
* @param {unknown} value
|
|
245
|
+
* @param {string} where
|
|
246
|
+
* @returns {void}
|
|
247
|
+
*/
|
|
248
|
+
export function assertJsonValue(value, where) {
|
|
249
|
+
assertJsonInput(value, 0, where, { nodes: 0 });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* @param {unknown} value
|
|
254
|
+
* @param {number} depth
|
|
255
|
+
* @param {string} where
|
|
256
|
+
* @param {{ nodes: number }} [budget] Document limits; request envelopes are validated separately.
|
|
257
|
+
* @returns {void}
|
|
258
|
+
*/
|
|
259
|
+
function assertJsonInput(value, depth, where, budget) {
|
|
260
|
+
if (budget !== undefined && ++budget.nodes > 4096)
|
|
261
|
+
throw new TypeError(`${where} holds at most 4096 values.`);
|
|
262
|
+
if (depth > 64) throw new TypeError(`${where} is nested too deeply.`);
|
|
263
|
+
if (value === null || typeof value === "boolean") return;
|
|
264
|
+
if (typeof value === "string") {
|
|
265
|
+
if (/[\uD800-\uDFFF]/u.test(value)) throw new TypeError(`${where} contains a lone surrogate.`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (typeof value === "bigint" || typeof value === "number" || value instanceof ExactDecimal) {
|
|
269
|
+
requireProducerNumber(String(value), where);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (budget !== undefined && typeof value === "object" && depth >= 16)
|
|
273
|
+
throw new TypeError(`${where} nests at most 16 levels.`);
|
|
274
|
+
if (Array.isArray(value)) {
|
|
275
|
+
for (const item of value) assertJsonInput(item, depth + 1, where, budget);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (typeof value === "object") {
|
|
279
|
+
const prototype = Object.getPrototypeOf(value);
|
|
280
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
281
|
+
throw new TypeError(`${where} contains a non-plain object.`);
|
|
282
|
+
for (const [key, item] of Object.entries(value)) {
|
|
283
|
+
if (/[\uD800-\uDFFF]/u.test(key)) throw new TypeError(`${where} contains a lone surrogate member name.`);
|
|
284
|
+
if (item === undefined)
|
|
285
|
+
throw new TypeError(`${where} contains an undefined member.`);
|
|
286
|
+
assertJsonInput(item, depth + 1, where, budget);
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
throw new TypeError(`${where} contains a value that is not JSON.`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Encodes a request value exactly. bigint and ExactDecimal keep every digit.
|
|
295
|
+
* @param {unknown} value
|
|
296
|
+
* @param {string} where
|
|
297
|
+
* @returns {string}
|
|
298
|
+
*/
|
|
299
|
+
export function stringifyExactJson(value, where) {
|
|
300
|
+
requireSourceAccess();
|
|
301
|
+
assertJsonInput(value, 0, where);
|
|
302
|
+
const raw = rawJsonFactory();
|
|
303
|
+
return JSON.stringify(value, (_key, item) =>
|
|
304
|
+
typeof item === "bigint" ? raw(item.toString()) : item,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* @param {unknown} value
|
|
310
|
+
* @param {string} where
|
|
311
|
+
* @returns {void}
|
|
312
|
+
*/
|
|
313
|
+
export function assertJsonObject(value, where) {
|
|
314
|
+
if (
|
|
315
|
+
typeof value !== "object" ||
|
|
316
|
+
value === null ||
|
|
317
|
+
Array.isArray(value) ||
|
|
318
|
+
value instanceof ExactDecimal
|
|
319
|
+
)
|
|
320
|
+
throw new TypeError(`${where} must be a JSON object.`);
|
|
321
|
+
assertJsonValue(value, where);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @param {unknown} value
|
|
326
|
+
* @returns {value is JsonObject}
|
|
327
|
+
*/
|
|
328
|
+
export function isJsonObject(value) {
|
|
329
|
+
return (
|
|
330
|
+
typeof value === "object" &&
|
|
331
|
+
value !== null &&
|
|
332
|
+
!Array.isArray(value) &&
|
|
333
|
+
!(value instanceof ExactDecimal)
|
|
334
|
+
);
|
|
335
|
+
}
|
package/src/v1/routes.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Routes owned by the C# Documents API at 0248ad3 (decision 0004 and shape-reads.md).
|
|
2
|
+
/** @typedef {"getDocument" | "putDocument" | "createDocument" | "patchDocument" | "deleteDocument" | "commitTransaction" | "queryDocuments" | "createCollection" | "getCollection" | "listCollections" | "dropCollection" | "readShape"} V1Operation */
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {object} V1Route
|
|
5
|
+
* @property {V1Operation} operation
|
|
6
|
+
* @property {"GET" | "PUT" | "POST" | "PATCH" | "DELETE"} method
|
|
7
|
+
* @property {string} pathTemplate
|
|
8
|
+
* @property {"documents:read" | "documents:write" | "shapes:read"} permission
|
|
9
|
+
* @property {readonly string[]} requestMediaTypes
|
|
10
|
+
* @property {string} responseMediaType
|
|
11
|
+
* @property {readonly number[]} successStatuses
|
|
12
|
+
* @property {"none"} idempotencyKey The producer rejects Idempotency-Key.
|
|
13
|
+
* @property {boolean} replaySafe Informational only; requests are never automatically replayed.
|
|
14
|
+
* @property {string} contract
|
|
15
|
+
* @property {readonly string[]} contractText
|
|
16
|
+
*/
|
|
17
|
+
const database = "/v1/projects/{project}/databases/{database}";
|
|
18
|
+
const collections = `${database}/collections`;
|
|
19
|
+
const collection = `${collections}/{collection}`;
|
|
20
|
+
const documents = `${collection}/documents`;
|
|
21
|
+
/** @type {readonly V1Route[]} */
|
|
22
|
+
export const v1Routes = Object.freeze(/** @type {V1Route[]} */ ([
|
|
23
|
+
{ operation: "getDocument", method: "GET", pathTemplate: `${documents}/{id}`, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
|
|
24
|
+
{ operation: "putDocument", method: "PUT", pathTemplate: `${documents}/{id}`, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [200, 201] },
|
|
25
|
+
{ operation: "createDocument", method: "POST", pathTemplate: documents, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [201] },
|
|
26
|
+
{ operation: "patchDocument", method: "PATCH", pathTemplate: `${documents}/{id}`, permission: "documents:write", requestMediaTypes: ["application/merge-patch+json", "application/vnd.terrabase.ops+json"], successStatuses: [200] },
|
|
27
|
+
{ operation: "deleteDocument", method: "DELETE", pathTemplate: `${documents}/{id}`, permission: "documents:write", requestMediaTypes: [], successStatuses: [200] },
|
|
28
|
+
{ operation: "commitTransaction", method: "POST", pathTemplate: `${database}/transactions`, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [200] },
|
|
29
|
+
{ operation: "queryDocuments", method: "POST", pathTemplate: `${collection}/query`, permission: "documents:read", requestMediaTypes: ["application/json"], successStatuses: [200] },
|
|
30
|
+
{ operation: "createCollection", method: "POST", pathTemplate: collections, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [201] },
|
|
31
|
+
{ operation: "getCollection", method: "GET", pathTemplate: collection, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
|
|
32
|
+
{ operation: "listCollections", method: "GET", pathTemplate: collections, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
|
|
33
|
+
{ operation: "dropCollection", method: "DELETE", pathTemplate: collection, permission: "documents:write", requestMediaTypes: [], successStatuses: [200] },
|
|
34
|
+
{ operation: "readShape", method: "GET", pathTemplate: `${collection}/shape`, permission: "shapes:read", requestMediaTypes: [], successStatuses: [200, 307] },
|
|
35
|
+
].map(route => ({
|
|
36
|
+
...route, responseMediaType: "application/json", idempotencyKey: "none",
|
|
37
|
+
replaySafe: route.permission !== "documents:write",
|
|
38
|
+
contract: route.operation === "readShape" ? "docs/shape-reads.md" : "docs/decisions/0004-document-api-v1.md",
|
|
39
|
+
contractText: [],
|
|
40
|
+
}))).map(route => Object.freeze({
|
|
41
|
+
...route, requestMediaTypes: Object.freeze(route.requestMediaTypes),
|
|
42
|
+
successStatuses: Object.freeze(route.successStatuses), contractText: Object.freeze(route.contractText),
|
|
43
|
+
})));
|
|
44
|
+
/** @param {V1Operation} operation @returns {V1Route} */
|
|
45
|
+
export function v1Route(operation) {
|
|
46
|
+
const route = v1Routes.find(candidate => candidate.operation === operation);
|
|
47
|
+
if (route === undefined) throw new TypeError(`Unknown v1 operation ${operation}.`);
|
|
48
|
+
return route;
|
|
49
|
+
}
|
|
50
|
+
/** @param {V1Route} route @param {Readonly<Record<string, string>>} params @returns {string} */
|
|
51
|
+
export function v1RoutePath(route, params) {
|
|
52
|
+
return route.pathTemplate.replace(/\{([a-z_]+)\}/gu, (_match, name) => {
|
|
53
|
+
const value = params[name];
|
|
54
|
+
if (value === undefined) throw new TypeError(`Missing path parameter ${name}.`);
|
|
55
|
+
return encodeURIComponent(value);
|
|
56
|
+
});
|
|
57
|
+
}
|
package/src/v1/shapes.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Current C# GET collection/shape contract (docs/shape-reads.md).
|
|
2
|
+
import { array, boolean, malformed, object, string, u64 } from "./decode.js";
|
|
3
|
+
import { requireFilter } from "./filter.js";
|
|
4
|
+
import { parseU64, requireVersion } from "./ids.js";
|
|
5
|
+
import { stringifyExactJson } from "./json.js";
|
|
6
|
+
/**
|
|
7
|
+
* @import { JsonObject, JsonValue } from "./json.js"
|
|
8
|
+
* @import { V1Filter } from "./filter.js"
|
|
9
|
+
* @import { V1Transport } from "./transport.js"
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} V1ShapeRequest
|
|
13
|
+
* @property {V1Filter | undefined} [where] Filter using the current query grammar; projection is unsupported.
|
|
14
|
+
* @property {string | undefined} [offset] -1 (default, server bootstrap only), snap.<head>.<base64url-id>, or a decimal log position.
|
|
15
|
+
* @property {string | undefined} [handle] Required after bootstrap.
|
|
16
|
+
* @property {boolean | undefined} [live] True only for log positions; waits up to 20 seconds.
|
|
17
|
+
* @property {string | undefined} [cursor] Previous live response's TB-Shape-Cursor.
|
|
18
|
+
*/
|
|
19
|
+
/** @typedef {{ readonly op: "upsert"; readonly id: string; readonly version: string; readonly document: JsonObject } | { readonly op: "delete"; readonly id: string }} V1ShapeOp */
|
|
20
|
+
/** @typedef {{ readonly offset: string; readonly ops: readonly V1ShapeOp[] }} V1ShapeChange */
|
|
21
|
+
/**
|
|
22
|
+
* One snapshot page or log chunk. Only a fresh open tail/live response can report upToDate.
|
|
23
|
+
* @typedef {object} V1ShapePage
|
|
24
|
+
* @property {string} shapeId
|
|
25
|
+
* @property {string} handle
|
|
26
|
+
* @property {string} offset
|
|
27
|
+
* @property {string} nextOffset
|
|
28
|
+
* @property {boolean} upToDate
|
|
29
|
+
* @property {readonly V1ShapeChange[]} changes Apply commits and operations in their received order.
|
|
30
|
+
* @property {string | undefined} cursor Echo this as cursor on the next live request.
|
|
31
|
+
* @property {string | undefined} etag
|
|
32
|
+
*/
|
|
33
|
+
const maximumOffset = (1n << 63n) - 1n;
|
|
34
|
+
const handlePattern = /^h1-[0-9a-f]{16}$/u;
|
|
35
|
+
/** @param {unknown} value @returns {boolean} */
|
|
36
|
+
function isLogOffset(value) {
|
|
37
|
+
const parsed = parseU64(value);
|
|
38
|
+
return parsed !== undefined && parsed <= maximumOffset;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Validates canonical C# offsets. Snapshot positions use a UTF-8/base64url keyset, not a page number.
|
|
42
|
+
* @param {unknown} value
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function requireShapeOffset(value) {
|
|
46
|
+
if (value === "-1" || isLogOffset(value)) return /** @type {string} */ (value);
|
|
47
|
+
if (typeof value !== "string") throw new TypeError("A shape offset is a string.");
|
|
48
|
+
const match = /^snap\.([0-9]+)\.(0|[A-Za-z0-9_-]{2,})$/u.exec(value);
|
|
49
|
+
if (match !== null && isLogOffset(match[1])) {
|
|
50
|
+
const encoded = match[2] ?? "";
|
|
51
|
+
if (encoded === "0") return value;
|
|
52
|
+
try {
|
|
53
|
+
const bytes = Uint8Array.from(atob(encoded.replace(/-/gu, "+").replace(/_/gu, "/")), c => c.charCodeAt(0));
|
|
54
|
+
const id = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
55
|
+
const canonical = btoa(String.fromCharCode(...new TextEncoder().encode(id))).replace(/\+/gu, "-").replace(/\//gu, "_").replace(/=+$/u, "");
|
|
56
|
+
if (canonical === encoded) return value;
|
|
57
|
+
} catch { /* Invalid base64url or UTF-8. */ }
|
|
58
|
+
}
|
|
59
|
+
throw new TypeError("A shape offset is -1, a signed-64-bit decimal log position, or snap.<head>.<canonical-base64url-id>.");
|
|
60
|
+
}
|
|
61
|
+
/** @param {unknown} value @returns {string} */
|
|
62
|
+
function requireHandle(value) {
|
|
63
|
+
if (typeof value !== "string" || !handlePattern.test(value)) throw new TypeError("A shape handle is h1- followed by 16 lowercase hex characters.");
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
/** @param {V1ShapeRequest} request @returns {URLSearchParams} */
|
|
67
|
+
export function encodeShapeRequest(request) {
|
|
68
|
+
if (typeof request !== "object" || request === null || Array.isArray(request)) throw new TypeError("A shape request is an object.");
|
|
69
|
+
for (const key of Object.keys(request))
|
|
70
|
+
if (!["where", "offset", "handle", "live", "cursor"].includes(key)) throw new TypeError(`Unknown shape member ${key}.`);
|
|
71
|
+
const query = new URLSearchParams();
|
|
72
|
+
if (request.where !== undefined) query.set("where", stringifyExactJson(requireFilter(request.where), "shape filter"));
|
|
73
|
+
const offset = requireShapeOffset(request.offset ?? "-1");
|
|
74
|
+
query.set("offset", offset);
|
|
75
|
+
if (request.handle !== undefined) query.set("handle", requireHandle(request.handle));
|
|
76
|
+
else if (offset !== "-1") throw new TypeError("A shape handle is required after bootstrap.");
|
|
77
|
+
if (request.live !== undefined && typeof request.live !== "boolean") throw new TypeError("live is a boolean.");
|
|
78
|
+
if (request.live === true) {
|
|
79
|
+
if (!isLogOffset(offset)) throw new TypeError("live requires a log offset.");
|
|
80
|
+
query.set("live", "true");
|
|
81
|
+
}
|
|
82
|
+
if (request.cursor !== undefined) {
|
|
83
|
+
if (request.live !== true || !isLogOffset(request.cursor) || BigInt(request.cursor) === maximumOffset)
|
|
84
|
+
throw new TypeError("cursor requires live=true and a decimal below Int64.MaxValue.");
|
|
85
|
+
query.set("cursor", request.cursor);
|
|
86
|
+
}
|
|
87
|
+
return query;
|
|
88
|
+
}
|
|
89
|
+
/** @param {JsonValue | undefined} value @param {Headers} headers @returns {V1ShapePage} */
|
|
90
|
+
export function decodeShapePage(value, headers) {
|
|
91
|
+
const raw = object(value, "shape page");
|
|
92
|
+
const shapeId = string(raw.shape_id, "shape_id");
|
|
93
|
+
if (!/^[0-9a-f]{32}$/u.test(shapeId)) malformed("shape_id");
|
|
94
|
+
const cursor = headers.get("tb-shape-cursor") ?? undefined;
|
|
95
|
+
if (cursor !== undefined && !isLogOffset(cursor)) malformed("shape cursor");
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
shapeId, handle: requireHandle(raw.handle),
|
|
98
|
+
offset: requireShapeOffset(raw.offset), nextOffset: requireShapeOffset(raw.next_offset),
|
|
99
|
+
upToDate: boolean(raw.up_to_date, "shape up_to_date"), cursor,
|
|
100
|
+
etag: headers.get("etag") ?? undefined,
|
|
101
|
+
changes: Object.freeze(array(raw.changes, "shape changes").map(value => {
|
|
102
|
+
const change = object(value, "shape change");
|
|
103
|
+
return Object.freeze({ offset: u64(change.offset, "shape change offset"), ops: Object.freeze(array(change.ops, "shape operations").map(value => {
|
|
104
|
+
const op = object(value, "shape operation");
|
|
105
|
+
// Shape snapshots can include stored legacy ids beyond the HTTP point-id limit.
|
|
106
|
+
const id = string(op.id, "shape document id");
|
|
107
|
+
if (op.op === "delete") return Object.freeze({ op: /** @type {const} */ ("delete"), id });
|
|
108
|
+
if (op.op !== "upsert") malformed("shape operation kind");
|
|
109
|
+
return Object.freeze({ op: /** @type {const} */ ("upsert"), id, version: requireVersion(op.version), document: object(op.document, "shape document") });
|
|
110
|
+
})) });
|
|
111
|
+
})),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Bootstrap extracts continuation metadata from 307 but never forwards credentials to Location.
|
|
116
|
+
* The next request is rebuilt against the same named route and configured endpoint.
|
|
117
|
+
* @param {V1Transport} transport
|
|
118
|
+
* @param {Readonly<Record<string, string>>} params
|
|
119
|
+
* @param {V1ShapeRequest} request
|
|
120
|
+
* @param {AbortSignal | undefined} signal
|
|
121
|
+
* @returns {Promise<V1ShapePage>}
|
|
122
|
+
*/
|
|
123
|
+
export async function readShape(transport, params, request, signal) {
|
|
124
|
+
const query = encodeShapeRequest(request);
|
|
125
|
+
let response = await transport.call("readShape", params, { query, signal });
|
|
126
|
+
if (response.status === 307) {
|
|
127
|
+
if (query.get("offset") !== "-1") malformed("unexpected shape redirect");
|
|
128
|
+
const source = new URL(await transport.url("readShape", params, signal));
|
|
129
|
+
const location = response.headers.get("location");
|
|
130
|
+
if (location === null) malformed("shape redirect Location");
|
|
131
|
+
const target = new URL(location, source);
|
|
132
|
+
if (target.origin !== source.origin || target.pathname !== source.pathname || target.username !== "" || target.password !== "" || target.hash !== "")
|
|
133
|
+
malformed("shape redirect target");
|
|
134
|
+
const handle = requireHandle(target.searchParams.get("handle"));
|
|
135
|
+
const offset = requireShapeOffset(target.searchParams.get("offset"));
|
|
136
|
+
if (!offset.startsWith("snap.")) malformed("shape bootstrap offset");
|
|
137
|
+
query.set("handle", handle);
|
|
138
|
+
query.set("offset", offset);
|
|
139
|
+
response = await transport.call("readShape", params, { query, signal });
|
|
140
|
+
if (response.status !== 200) malformed("repeated shape redirect");
|
|
141
|
+
}
|
|
142
|
+
const page = decodeShapePage(response.value, response.headers);
|
|
143
|
+
if (page.offset !== query.get("offset") || (query.has("handle") && page.handle !== query.get("handle"))) malformed("shape response position");
|
|
144
|
+
return page;
|
|
145
|
+
}
|