terrascale 0.3.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/LICENSE +202 -0
- package/README.md +143 -0
- package/package.json +159 -0
- package/sdk-current-contract.json +27 -0
- package/sdk-route-manifest.json +67 -0
- package/src/admin.js +9 -0
- package/src/better-auth.js +14 -0
- package/src/config.js +121 -0
- package/src/database-codec.js +845 -0
- package/src/database-types.js +237 -0
- package/src/database-view.js +422 -0
- package/src/database.js +420 -0
- package/src/discovery.js +374 -0
- package/src/http.js +887 -0
- package/src/index.js +79 -0
- package/src/local/authentication.js +47 -0
- package/src/local/better-auth.js +517 -0
- package/src/local/cli.js +51 -0
- package/src/local/context.js +23 -0
- package/src/local/environment.js +109 -0
- package/src/local/index.js +204 -0
- package/src/local/router.js +999 -0
- package/src/local/server.js +664 -0
- package/src/local/store.js +530 -0
- package/src/local/test-environment.js +74 -0
- package/src/management-contracts.js +72 -0
- package/src/management.js +12 -0
- package/src/native-origin.js +75 -0
- package/src/postgres.js +494 -0
- package/src/react/core.js +743 -0
- package/src/react/index.js +99 -0
- package/src/result.js +251 -0
- package/src/schema.js +366 -0
- package/src/sql.js +996 -0
- package/src/svelte/index.js +129 -0
- package/src/tanstack/index.js +511 -0
- package/src/ts-auth-discovery.js +190 -0
- package/src/ts-auth.js +3497 -0
- package/types/admin.d.ts +6 -0
- package/types/better-auth.d.ts +8 -0
- package/types/config.d.ts +58 -0
- package/types/database-codec.d.ts +111 -0
- package/types/database-types.d.ts +213 -0
- package/types/database-view.d.ts +183 -0
- package/types/database.d.ts +98 -0
- package/types/discovery.d.ts +114 -0
- package/types/http.d.ts +46 -0
- package/types/index.d.ts +52 -0
- package/types/local/authentication.d.ts +11 -0
- package/types/local/better-auth.d.ts +33 -0
- package/types/local/cli.d.ts +2 -0
- package/types/local/context.d.ts +14 -0
- package/types/local/environment.d.ts +23 -0
- package/types/local/index.d.ts +94 -0
- package/types/local/router.d.ts +66 -0
- package/types/local/server.d.ts +54 -0
- package/types/local/store.d.ts +106 -0
- package/types/local/test-environment.d.ts +25 -0
- package/types/management-contracts.d.ts +44 -0
- package/types/management.d.ts +6 -0
- package/types/native-origin.d.ts +23 -0
- package/types/postgres.d.ts +123 -0
- package/types/react/core.d.ts +366 -0
- package/types/react/index.d.ts +54 -0
- package/types/result.d.ts +161 -0
- package/types/schema.d.ts +145 -0
- package/types/sql.d.ts +288 -0
- package/types/svelte/index.d.ts +81 -0
- package/types/tanstack/index.d.ts +165 -0
- package/types/ts-auth-discovery.d.ts +11 -0
- package/types/ts-auth.d.ts +1826 -0
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHash,
|
|
3
|
+
createHmac,
|
|
4
|
+
randomBytes,
|
|
5
|
+
randomUUID,
|
|
6
|
+
timingSafeEqual,
|
|
7
|
+
} from "node:crypto";
|
|
8
|
+
import {
|
|
9
|
+
decodeDatabaseAuthority,
|
|
10
|
+
decodeDatabasePoint,
|
|
11
|
+
decodeDatabaseStatus,
|
|
12
|
+
decodeDatabaseValue,
|
|
13
|
+
encodeDatabaseAuthority,
|
|
14
|
+
encodeDatabaseCommit,
|
|
15
|
+
encodeDatabaseQuery,
|
|
16
|
+
encodeDatabaseStatus,
|
|
17
|
+
encodeDatabaseSubscription,
|
|
18
|
+
encodeDatabaseValue,
|
|
19
|
+
parseDatabaseJson,
|
|
20
|
+
requireDatabaseU64,
|
|
21
|
+
requireDatabaseUuid,
|
|
22
|
+
} from "../database-codec.js";
|
|
23
|
+
import { matchesLocalBearerAuthorization } from "./authentication.js";
|
|
24
|
+
import { jsonResponse, problemResponse } from "./context.js";
|
|
25
|
+
/**
|
|
26
|
+
* @import {
|
|
27
|
+
* CollectionBinding,
|
|
28
|
+
* DatabaseCommit,
|
|
29
|
+
* DatabaseCondition,
|
|
30
|
+
* DatabaseField,
|
|
31
|
+
* DatabasePoint,
|
|
32
|
+
* DatabaseQueryPredicate,
|
|
33
|
+
* DatabaseScope,
|
|
34
|
+
* DatabaseValue,
|
|
35
|
+
* DatabaseWrite,
|
|
36
|
+
* } from "../database-types.js"
|
|
37
|
+
*/
|
|
38
|
+
/** @import { FetchLike } from "../config.js" */
|
|
39
|
+
/** @import { LocalAuthenticationMode } from "./context.js" */
|
|
40
|
+
/** @import { TerraScaleSqliteStore } from "./store.js" */
|
|
41
|
+
|
|
42
|
+
/** @typedef {Record<string, unknown>} JsonObject */
|
|
43
|
+
/** @typedef {JsonObject} WirePoint */
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {{
|
|
46
|
+
* readonly sequence: string;
|
|
47
|
+
* readonly entries: readonly {
|
|
48
|
+
* readonly collection: string;
|
|
49
|
+
* readonly document: string;
|
|
50
|
+
* readonly before: WirePoint;
|
|
51
|
+
* readonly after: WirePoint;
|
|
52
|
+
* }[];
|
|
53
|
+
* }} Change
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {{
|
|
57
|
+
* readonly route: string;
|
|
58
|
+
* readonly predicate: string;
|
|
59
|
+
* readonly limit: number;
|
|
60
|
+
* readonly sequence: string;
|
|
61
|
+
* readonly rows: readonly {
|
|
62
|
+
* readonly document: string;
|
|
63
|
+
* readonly point: WirePoint;
|
|
64
|
+
* }[];
|
|
65
|
+
* readonly expires: number;
|
|
66
|
+
* }} Snapshot
|
|
67
|
+
*/
|
|
68
|
+
const absent = {
|
|
69
|
+
version: 1,
|
|
70
|
+
state: "absent",
|
|
71
|
+
state_version: null,
|
|
72
|
+
context: [],
|
|
73
|
+
siblings: [],
|
|
74
|
+
};
|
|
75
|
+
const maximumBodyBytes = 4 * 1024 * 1024;
|
|
76
|
+
|
|
77
|
+
/** Development implementation of the admitted native grammar, never producer qualification. */
|
|
78
|
+
export class TerraScaleLocalRouter {
|
|
79
|
+
/** @type {TerraScaleSqliteStore} */
|
|
80
|
+
#store;
|
|
81
|
+
/** @type {DatabaseScope} */
|
|
82
|
+
#scope;
|
|
83
|
+
/** @type {string} */
|
|
84
|
+
#scopeKey;
|
|
85
|
+
/** @type {string} */
|
|
86
|
+
#path;
|
|
87
|
+
/** @type {string | undefined} */
|
|
88
|
+
#token;
|
|
89
|
+
#secret = randomBytes(32);
|
|
90
|
+
/** @type {{ readonly replica: string; readonly incarnation: string }} */
|
|
91
|
+
#writer;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {TerraScaleSqliteStore} store
|
|
95
|
+
* @param {DatabaseScope} scope
|
|
96
|
+
* @param {{
|
|
97
|
+
* readonly authentication: LocalAuthenticationMode;
|
|
98
|
+
* readonly bearerToken?: string;
|
|
99
|
+
* }} options
|
|
100
|
+
*/
|
|
101
|
+
constructor(store, scope, options) {
|
|
102
|
+
this.#store = store;
|
|
103
|
+
this.#scope = Object.freeze({
|
|
104
|
+
tenant: requireDatabaseUuid(scope.tenant),
|
|
105
|
+
database: requireDatabaseUuid(scope.database),
|
|
106
|
+
shard: requireDatabaseU64(scope.shard),
|
|
107
|
+
});
|
|
108
|
+
this.#scopeKey = `${scope.tenant}/${scope.database}/${scope.shard}`;
|
|
109
|
+
this.#path = `/v1/tenants/${scope.tenant}/databases/${scope.database}/shards/${scope.shard}`;
|
|
110
|
+
this.#token =
|
|
111
|
+
options.authentication === "required" ? options.bearerToken : undefined;
|
|
112
|
+
const writer = /** @type {{ readonly replica: string; readonly incarnation: string } | undefined} */ (
|
|
113
|
+
store.getMeta(`native-writer:${this.#scopeKey}`)
|
|
114
|
+
) ?? {
|
|
115
|
+
replica: randomUUID(),
|
|
116
|
+
incarnation: randomBytes(16).toString("hex"),
|
|
117
|
+
};
|
|
118
|
+
this.#writer = Object.freeze(writer);
|
|
119
|
+
store.setMeta(`native-writer:${this.#scopeKey}`, writer);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
get authority() {
|
|
123
|
+
return Object.freeze({
|
|
124
|
+
mode: /** @type {const} */ ("single_writer"),
|
|
125
|
+
generation: 1n,
|
|
126
|
+
writer: this.#writer,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* @param {CollectionBinding} binding
|
|
131
|
+
* @returns {void}
|
|
132
|
+
*/
|
|
133
|
+
bindCollection(binding) {
|
|
134
|
+
requireDatabaseUuid(binding.collection);
|
|
135
|
+
requireDatabaseU64(binding.schema_generation, "schema generation", true);
|
|
136
|
+
if (!/^[0-9a-f]{64}$/u.test(binding.schema_digest))
|
|
137
|
+
throw new TypeError("Schema digest must be lowercase SHA256.");
|
|
138
|
+
this.#store.put("native-catalog", this.#scopeKey, binding.collection, {
|
|
139
|
+
...binding,
|
|
140
|
+
schema_generation: binding.schema_generation.toString(),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* @readonly
|
|
145
|
+
* @type {FetchLike}
|
|
146
|
+
*/
|
|
147
|
+
fetch = async (input, init) => this.handle(new Request(input, init));
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {Request} request
|
|
151
|
+
* @returns {Promise<Response>}
|
|
152
|
+
*/
|
|
153
|
+
async handle(request) {
|
|
154
|
+
if (
|
|
155
|
+
this.#token !== undefined &&
|
|
156
|
+
!matchesLocalBearerAuthorization(
|
|
157
|
+
request.headers.get("authorization"),
|
|
158
|
+
this.#token,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
return problemResponse(401, "invalid_key");
|
|
162
|
+
try {
|
|
163
|
+
request.signal.throwIfAborted();
|
|
164
|
+
const url = new URL(request.url);
|
|
165
|
+
if (url.search !== "" || !url.pathname.startsWith(`${this.#path}/`))
|
|
166
|
+
return problemResponse(403, "forbidden_scope");
|
|
167
|
+
const suffix = url.pathname.slice(this.#path.length);
|
|
168
|
+
if (request.method === "GET") {
|
|
169
|
+
const match = /^\/collections\/([^/]+)\/documents\/([^/]+)$/u.exec(
|
|
170
|
+
suffix,
|
|
171
|
+
);
|
|
172
|
+
if (match === null) return problemResponse(404, "unknown_route");
|
|
173
|
+
const collection = requireDatabaseUuid(match[1]);
|
|
174
|
+
const document = requireDatabaseUuid(match[2]);
|
|
175
|
+
return await this.#store.exclusive(() => {
|
|
176
|
+
this.#binding(collection);
|
|
177
|
+
return jsonResponse(this.#point(collection, document));
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (request.method !== "POST")
|
|
181
|
+
return problemResponse(405, "method_not_allowed");
|
|
182
|
+
if (
|
|
183
|
+
request.headers.get("content-type")?.split(";", 1)[0]?.trim() !==
|
|
184
|
+
"application/json"
|
|
185
|
+
)
|
|
186
|
+
return problemResponse(400, "invalid_request");
|
|
187
|
+
const raw = object(await readBody(request));
|
|
188
|
+
if (suffix === "/transactions") {
|
|
189
|
+
const commit = decodeCommit(raw);
|
|
190
|
+
const encoded = await encodeDatabaseCommit(this.#scope, commit);
|
|
191
|
+
if (encoded.effect_digest !== raw.effect_digest)
|
|
192
|
+
return problemResponse(400, "invalid_request");
|
|
193
|
+
return await this.#store.exclusive(() =>
|
|
194
|
+
this.#store.transaction(() => this.#commit(raw, commit)),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (suffix === "/transactions/status") {
|
|
198
|
+
const identity = decodeDatabaseStatus({
|
|
199
|
+
...raw,
|
|
200
|
+
state: "unknown",
|
|
201
|
+
outcome: null,
|
|
202
|
+
});
|
|
203
|
+
exactKeys(raw, [
|
|
204
|
+
"version",
|
|
205
|
+
"transaction_id",
|
|
206
|
+
"authority",
|
|
207
|
+
"effect_digest",
|
|
208
|
+
]);
|
|
209
|
+
encodeDatabaseStatus({
|
|
210
|
+
transaction_id: identity.transaction_id,
|
|
211
|
+
authority: identity.authority,
|
|
212
|
+
effect_digest: identity.effect_digest,
|
|
213
|
+
});
|
|
214
|
+
return await this.#store.exclusive(() => {
|
|
215
|
+
const result = /** @type {JsonObject | undefined} */ (
|
|
216
|
+
this.#store.get(
|
|
217
|
+
"native-transactions",
|
|
218
|
+
this.#scopeKey,
|
|
219
|
+
transactionKey(raw),
|
|
220
|
+
)
|
|
221
|
+
);
|
|
222
|
+
if (result !== undefined) {
|
|
223
|
+
if (
|
|
224
|
+
result.effect_digest !== raw.effect_digest ||
|
|
225
|
+
JSON.stringify(result.authority) !== JSON.stringify(raw.authority)
|
|
226
|
+
)
|
|
227
|
+
return problemResponse(409, "idempotency_conflict");
|
|
228
|
+
if (
|
|
229
|
+
identity.transaction_id.expires_at_unix_ms <= BigInt(Date.now())
|
|
230
|
+
)
|
|
231
|
+
return jsonResponse({ ...raw, state: "expired", outcome: null });
|
|
232
|
+
return jsonResponse(result);
|
|
233
|
+
}
|
|
234
|
+
return jsonResponse({
|
|
235
|
+
...raw,
|
|
236
|
+
state:
|
|
237
|
+
identity.transaction_id.expires_at_unix_ms <= BigInt(Date.now())
|
|
238
|
+
? "expired"
|
|
239
|
+
: "unknown",
|
|
240
|
+
outcome: null,
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
const match =
|
|
245
|
+
/^\/collections\/([^/]+)\/documents\/(query|subscriptions)$/u.exec(
|
|
246
|
+
suffix,
|
|
247
|
+
);
|
|
248
|
+
if (match === null) return problemResponse(404, "unknown_route");
|
|
249
|
+
const collection = requireDatabaseUuid(match[1]);
|
|
250
|
+
return await this.#store.exclusive(() => {
|
|
251
|
+
this.#binding(collection);
|
|
252
|
+
return this.#query(collection, match[2] === "subscriptions", raw);
|
|
253
|
+
});
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (error instanceof NativeLocalError)
|
|
256
|
+
return problemResponse(error.status, error.code);
|
|
257
|
+
return problemResponse(400, "invalid_request");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {string} collection
|
|
263
|
+
* @returns {JsonObject}
|
|
264
|
+
*/
|
|
265
|
+
#binding(collection) {
|
|
266
|
+
const value = /** @type {JsonObject | undefined} */ (
|
|
267
|
+
this.#store.get("native-catalog", this.#scopeKey, collection)
|
|
268
|
+
);
|
|
269
|
+
if (value === undefined) throw new NativeLocalError(409, "schema_changed");
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* @param {string} collection
|
|
274
|
+
* @param {string} document
|
|
275
|
+
* @returns {WirePoint}
|
|
276
|
+
*/
|
|
277
|
+
#point(collection, document) {
|
|
278
|
+
return (
|
|
279
|
+
/** @type {WirePoint | undefined} */ (
|
|
280
|
+
this.#store.get(
|
|
281
|
+
"native-points",
|
|
282
|
+
`${this.#scopeKey}/${collection}`,
|
|
283
|
+
document,
|
|
284
|
+
)
|
|
285
|
+
) ?? absent
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
/** @returns {bigint} */
|
|
289
|
+
#sequence() {
|
|
290
|
+
return BigInt(
|
|
291
|
+
/** @type {string | undefined} */ (
|
|
292
|
+
this.#store.getMeta(`native-sequence:${this.#scopeKey}`)
|
|
293
|
+
) ?? "0",
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* @param {JsonObject} raw
|
|
299
|
+
* @param {DatabaseCommit} commit
|
|
300
|
+
* @returns {Response}
|
|
301
|
+
*/
|
|
302
|
+
#commit(raw, commit) {
|
|
303
|
+
const key = transactionKey(raw);
|
|
304
|
+
const previous = /** @type {JsonObject | undefined} */ (
|
|
305
|
+
this.#store.get("native-transactions", this.#scopeKey, key)
|
|
306
|
+
);
|
|
307
|
+
if (previous !== undefined) {
|
|
308
|
+
if (
|
|
309
|
+
previous.effect_digest !== raw.effect_digest ||
|
|
310
|
+
JSON.stringify(previous.authority) !== JSON.stringify(raw.authority)
|
|
311
|
+
)
|
|
312
|
+
return problemResponse(409, "idempotency_conflict");
|
|
313
|
+
if (commit.transaction_id.expires_at_unix_ms <= BigInt(Date.now()))
|
|
314
|
+
return problemResponse(409, "idempotency_expired");
|
|
315
|
+
return previous.state === "rejected"
|
|
316
|
+
? problemResponse(412, "precondition_failed")
|
|
317
|
+
: jsonResponse(object(previous.outcome).receipt);
|
|
318
|
+
}
|
|
319
|
+
if (commit.transaction_id.expires_at_unix_ms <= BigInt(Date.now()))
|
|
320
|
+
return problemResponse(409, "idempotency_expired");
|
|
321
|
+
if (
|
|
322
|
+
commit.authority.mode === "single_writer" &&
|
|
323
|
+
JSON.stringify(encodeDatabaseAuthority(commit.authority)) !==
|
|
324
|
+
JSON.stringify(encodeDatabaseAuthority(this.authority))
|
|
325
|
+
) {
|
|
326
|
+
return problemResponse(409, "authority_changed");
|
|
327
|
+
}
|
|
328
|
+
for (const write of commit.writes) {
|
|
329
|
+
const binding = this.#binding(write.collection);
|
|
330
|
+
if (
|
|
331
|
+
binding.schema_digest !== write.schema_digest ||
|
|
332
|
+
binding.schema_generation !== write.schema_generation.toString()
|
|
333
|
+
)
|
|
334
|
+
return problemResponse(409, "schema_changed");
|
|
335
|
+
const point = this.#point(write.collection, write.document);
|
|
336
|
+
if (
|
|
337
|
+
(write.condition.kind === "absent" && point.state !== "absent") ||
|
|
338
|
+
(write.condition.kind === "version" &&
|
|
339
|
+
point.state_version !== write.condition.version)
|
|
340
|
+
) {
|
|
341
|
+
this.#store.put("native-transactions", this.#scopeKey, key, {
|
|
342
|
+
version: 1,
|
|
343
|
+
transaction_id: raw.transaction_id,
|
|
344
|
+
authority: raw.authority,
|
|
345
|
+
effect_digest: raw.effect_digest,
|
|
346
|
+
state: "rejected",
|
|
347
|
+
outcome: { error: { code: "precondition_failed" } },
|
|
348
|
+
});
|
|
349
|
+
return problemResponse(412, "precondition_failed");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const sequence = this.#sequence() + 1n;
|
|
353
|
+
const event = {
|
|
354
|
+
database: this.#scope.database,
|
|
355
|
+
shard: this.#scope.shard.toString(),
|
|
356
|
+
...this.#writer,
|
|
357
|
+
sequence: sequence.toString(),
|
|
358
|
+
};
|
|
359
|
+
/** @type {Change["entries"][number][]} */
|
|
360
|
+
const entries = [];
|
|
361
|
+
for (const write of commit.writes) {
|
|
362
|
+
const before = this.#point(write.collection, write.document);
|
|
363
|
+
const point = decodeDatabasePoint(before);
|
|
364
|
+
const retained = point.siblings.filter(
|
|
365
|
+
(sibling) =>
|
|
366
|
+
!write.observed.some(
|
|
367
|
+
(observed) =>
|
|
368
|
+
observed.replica === sibling.event.replica &&
|
|
369
|
+
observed.incarnation === sibling.event.incarnation &&
|
|
370
|
+
observed.sequence >= sibling.event.sequence,
|
|
371
|
+
),
|
|
372
|
+
);
|
|
373
|
+
const sibling = {
|
|
374
|
+
event,
|
|
375
|
+
schema_generation: write.schema_generation.toString(),
|
|
376
|
+
schema_digest: write.schema_digest,
|
|
377
|
+
...wireWriteValue(write.value),
|
|
378
|
+
};
|
|
379
|
+
const siblings = [...retained.map(wireSibling), sibling];
|
|
380
|
+
const context = new Map(
|
|
381
|
+
point.context.map((observed) => [
|
|
382
|
+
observed.replica + observed.incarnation,
|
|
383
|
+
observed,
|
|
384
|
+
]),
|
|
385
|
+
);
|
|
386
|
+
const own = { ...this.#writer, sequence };
|
|
387
|
+
context.set(own.replica + own.incarnation, own);
|
|
388
|
+
for (const observed of write.observed) {
|
|
389
|
+
const old = context.get(observed.replica + observed.incarnation);
|
|
390
|
+
if (old === undefined || old.sequence < observed.sequence)
|
|
391
|
+
context.set(observed.replica + observed.incarnation, observed);
|
|
392
|
+
}
|
|
393
|
+
const after = {
|
|
394
|
+
version: 1,
|
|
395
|
+
state:
|
|
396
|
+
siblings.length > 1
|
|
397
|
+
? "conflict"
|
|
398
|
+
: write.value.kind === "live"
|
|
399
|
+
? "value"
|
|
400
|
+
: "deleted",
|
|
401
|
+
state_version:
|
|
402
|
+
siblings.length > 1
|
|
403
|
+
? null
|
|
404
|
+
: createHash("sha256")
|
|
405
|
+
.update(JSON.stringify(sibling))
|
|
406
|
+
.digest("hex"),
|
|
407
|
+
context: [...context.values()]
|
|
408
|
+
.sort((a, b) =>
|
|
409
|
+
(a.replica + a.incarnation).localeCompare(
|
|
410
|
+
b.replica + b.incarnation,
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
.map((observed) => ({
|
|
414
|
+
...observed,
|
|
415
|
+
sequence: observed.sequence.toString(),
|
|
416
|
+
})),
|
|
417
|
+
siblings,
|
|
418
|
+
};
|
|
419
|
+
decodeDatabasePoint(after);
|
|
420
|
+
this.#store.put(
|
|
421
|
+
"native-points",
|
|
422
|
+
`${this.#scopeKey}/${write.collection}`,
|
|
423
|
+
write.document,
|
|
424
|
+
after,
|
|
425
|
+
);
|
|
426
|
+
entries.push({
|
|
427
|
+
collection: write.collection,
|
|
428
|
+
document: write.document,
|
|
429
|
+
before,
|
|
430
|
+
after,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
const receipt = {
|
|
434
|
+
version: 1,
|
|
435
|
+
durability: "local_durable",
|
|
436
|
+
transaction_id: raw.transaction_id,
|
|
437
|
+
authority: raw.authority,
|
|
438
|
+
effect_digest: raw.effect_digest,
|
|
439
|
+
event,
|
|
440
|
+
commit_sequence: sequence.toString(),
|
|
441
|
+
};
|
|
442
|
+
this.#store.put("native-transactions", this.#scopeKey, key, {
|
|
443
|
+
version: 1,
|
|
444
|
+
transaction_id: raw.transaction_id,
|
|
445
|
+
authority: raw.authority,
|
|
446
|
+
effect_digest: raw.effect_digest,
|
|
447
|
+
state: "committed",
|
|
448
|
+
outcome: { receipt },
|
|
449
|
+
});
|
|
450
|
+
this.#store.put(
|
|
451
|
+
"native-changes",
|
|
452
|
+
this.#scopeKey,
|
|
453
|
+
sequence.toString().padStart(20, "0"),
|
|
454
|
+
{ sequence: sequence.toString(), entries },
|
|
455
|
+
);
|
|
456
|
+
this.#store.setMeta(
|
|
457
|
+
`native-sequence:${this.#scopeKey}`,
|
|
458
|
+
sequence.toString(),
|
|
459
|
+
);
|
|
460
|
+
return jsonResponse(receipt);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* @param {JsonObject} payload
|
|
465
|
+
* @returns {string}
|
|
466
|
+
*/
|
|
467
|
+
#cursor(payload) {
|
|
468
|
+
const value = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
469
|
+
const signed = `${value}_${createHmac("sha256", this.#secret).update(value).digest("base64url")}`;
|
|
470
|
+
return Buffer.from(signed).toString("base64url");
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* @param {unknown} value
|
|
474
|
+
* @param {string} route
|
|
475
|
+
* @param {string} predicate
|
|
476
|
+
* @returns {JsonObject}
|
|
477
|
+
*/
|
|
478
|
+
#cursorPayload(value, route, predicate) {
|
|
479
|
+
if (typeof value !== "string" || value.length > 1024)
|
|
480
|
+
throw new NativeLocalError(400, "invalid_cursor");
|
|
481
|
+
const signed = Buffer.from(value, "base64url").toString("utf8");
|
|
482
|
+
if (Buffer.from(signed).toString("base64url") !== value)
|
|
483
|
+
throw new NativeLocalError(400, "invalid_cursor");
|
|
484
|
+
const separator = signed.length - 44;
|
|
485
|
+
if (separator < 1 || signed[separator] !== "_")
|
|
486
|
+
throw new NativeLocalError(400, "invalid_cursor");
|
|
487
|
+
const body = signed.slice(0, separator);
|
|
488
|
+
const signature = signed.slice(separator + 1);
|
|
489
|
+
const expected = createHmac("sha256", this.#secret)
|
|
490
|
+
.update(body)
|
|
491
|
+
.digest("base64url");
|
|
492
|
+
if (
|
|
493
|
+
signature.length !== expected.length ||
|
|
494
|
+
!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
|
|
495
|
+
)
|
|
496
|
+
throw new NativeLocalError(400, "invalid_cursor");
|
|
497
|
+
const payload = object(
|
|
498
|
+
JSON.parse(Buffer.from(body, "base64url").toString("utf8")),
|
|
499
|
+
);
|
|
500
|
+
if (payload.route !== route || payload.predicate !== predicate)
|
|
501
|
+
throw new NativeLocalError(400, "invalid_cursor");
|
|
502
|
+
if (typeof payload.expires !== "number" || payload.expires <= Date.now())
|
|
503
|
+
throw new NativeLocalError(409, "resync_required");
|
|
504
|
+
return payload;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* @param {string} collection
|
|
508
|
+
* @param {boolean} subscription
|
|
509
|
+
* @param {JsonObject} raw
|
|
510
|
+
* @returns {Response}
|
|
511
|
+
*/
|
|
512
|
+
#query(collection, subscription, raw) {
|
|
513
|
+
for (const entry of /** @type {readonly { readonly scope: string; readonly id: string; readonly value: Snapshot }[]} */ (
|
|
514
|
+
this.#store.listNamespace("native-snapshots")
|
|
515
|
+
))
|
|
516
|
+
if (entry.value.expires <= Date.now())
|
|
517
|
+
this.#store.delete("native-snapshots", entry.scope, entry.id);
|
|
518
|
+
const predicate = decodePredicate(raw.predicate);
|
|
519
|
+
const predicateKey = createHash("sha256")
|
|
520
|
+
.update(JSON.stringify(raw.predicate))
|
|
521
|
+
.digest("hex");
|
|
522
|
+
const binding = this.#binding(collection);
|
|
523
|
+
const route = `${collection}/${subscription ? "subscription" : "query"}/${binding.schema_generation}/${binding.schema_digest}`;
|
|
524
|
+
if (subscription && raw.phase === "poll") {
|
|
525
|
+
encodeDatabaseSubscription({
|
|
526
|
+
phase: "poll",
|
|
527
|
+
predicate,
|
|
528
|
+
poll_cursor: text(raw.poll_cursor),
|
|
529
|
+
});
|
|
530
|
+
exactKeys(raw, ["version", "phase", "predicate", "poll_cursor"]);
|
|
531
|
+
const cursor = this.#cursorPayload(raw.poll_cursor, route, predicateKey);
|
|
532
|
+
const after = decimal(cursor.sequence);
|
|
533
|
+
const changes = /** @type {Change[]} */ (
|
|
534
|
+
this.#store.list("native-changes", this.#scopeKey)
|
|
535
|
+
)
|
|
536
|
+
.filter((change) => BigInt(change.sequence) > after)
|
|
537
|
+
.slice(0, 8);
|
|
538
|
+
const groups = changes.map((change) => ({
|
|
539
|
+
sequence: change.sequence,
|
|
540
|
+
effects: change.entries.flatMap(
|
|
541
|
+
/** @returns {{ document: string; point: JsonObject | null }[]} */
|
|
542
|
+
(entry) => {
|
|
543
|
+
if (entry.collection !== collection) return [];
|
|
544
|
+
const before = matchesPoint(entry.before, predicate);
|
|
545
|
+
const after = matchesPoint(entry.after, predicate);
|
|
546
|
+
return after
|
|
547
|
+
? [{ document: entry.document, point: entry.after }]
|
|
548
|
+
: before
|
|
549
|
+
? [{ document: entry.document, point: null }]
|
|
550
|
+
: [];
|
|
551
|
+
},
|
|
552
|
+
),
|
|
553
|
+
}));
|
|
554
|
+
if (groups.some((group) => group.effects.length > 128))
|
|
555
|
+
throw new NativeLocalError(409, "resync_required");
|
|
556
|
+
const sequence = changes.at(-1)?.sequence ?? cursor.sequence;
|
|
557
|
+
return jsonResponse({
|
|
558
|
+
version: 1,
|
|
559
|
+
phase: "poll",
|
|
560
|
+
groups,
|
|
561
|
+
poll_cursor: this.#cursor({
|
|
562
|
+
route,
|
|
563
|
+
predicate: predicateKey,
|
|
564
|
+
sequence,
|
|
565
|
+
expires: Date.now() + 60_000,
|
|
566
|
+
}),
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const limit = raw.limit;
|
|
570
|
+
if (typeof limit !== "number") throw new TypeError("Missing limit.");
|
|
571
|
+
if (subscription) {
|
|
572
|
+
encodeDatabaseSubscription({
|
|
573
|
+
phase: "snapshot",
|
|
574
|
+
predicate,
|
|
575
|
+
limit,
|
|
576
|
+
...(raw.snapshot_cursor === undefined
|
|
577
|
+
? {}
|
|
578
|
+
: { snapshot_cursor: text(raw.snapshot_cursor) }),
|
|
579
|
+
});
|
|
580
|
+
exactKeys(
|
|
581
|
+
raw,
|
|
582
|
+
["version", "phase", "predicate", "limit"],
|
|
583
|
+
["snapshot_cursor"],
|
|
584
|
+
);
|
|
585
|
+
if (raw.phase !== "snapshot") throw new TypeError("Invalid phase.");
|
|
586
|
+
} else {
|
|
587
|
+
encodeDatabaseQuery({
|
|
588
|
+
predicate,
|
|
589
|
+
limit,
|
|
590
|
+
...(raw.cursor === undefined ? {} : { cursor: text(raw.cursor) }),
|
|
591
|
+
});
|
|
592
|
+
exactKeys(raw, ["version", "predicate", "limit"], ["cursor"]);
|
|
593
|
+
}
|
|
594
|
+
if (raw.version !== 1) throw new TypeError("Invalid version.");
|
|
595
|
+
const cursorValue = subscription ? raw.snapshot_cursor : raw.cursor;
|
|
596
|
+
/** @type {Snapshot} */
|
|
597
|
+
let snapshot;
|
|
598
|
+
/** @type {string} */
|
|
599
|
+
let id;
|
|
600
|
+
let offset = 0;
|
|
601
|
+
if (cursorValue === undefined) {
|
|
602
|
+
id = randomUUID();
|
|
603
|
+
const rows = /** @type {readonly { readonly scope: string; readonly id: string; readonly value: WirePoint }[]} */ (
|
|
604
|
+
this.#store.listNamespace("native-points")
|
|
605
|
+
)
|
|
606
|
+
.filter(
|
|
607
|
+
(entry) =>
|
|
608
|
+
entry.scope === `${this.#scopeKey}/${collection}` &&
|
|
609
|
+
matchesPoint(entry.value, predicate),
|
|
610
|
+
)
|
|
611
|
+
.map((entry) => ({ document: entry.id, point: entry.value }));
|
|
612
|
+
snapshot = {
|
|
613
|
+
route,
|
|
614
|
+
predicate: predicateKey,
|
|
615
|
+
limit,
|
|
616
|
+
sequence: this.#sequence().toString(),
|
|
617
|
+
rows,
|
|
618
|
+
expires: Date.now() + 60_000,
|
|
619
|
+
};
|
|
620
|
+
this.#store.put("native-snapshots", this.#scopeKey, id, snapshot);
|
|
621
|
+
} else {
|
|
622
|
+
const cursor = this.#cursorPayload(cursorValue, route, predicateKey);
|
|
623
|
+
id = text(cursor.id);
|
|
624
|
+
offset = typeof cursor.offset === "number" ? cursor.offset : -1;
|
|
625
|
+
const existing = /** @type {Snapshot | undefined} */ (
|
|
626
|
+
this.#store.get("native-snapshots", this.#scopeKey, id)
|
|
627
|
+
);
|
|
628
|
+
if (
|
|
629
|
+
existing === undefined ||
|
|
630
|
+
existing.limit !== limit ||
|
|
631
|
+
existing.expires <= Date.now() ||
|
|
632
|
+
!Number.isSafeInteger(offset) ||
|
|
633
|
+
offset < 0
|
|
634
|
+
)
|
|
635
|
+
throw new NativeLocalError(409, "resync_required");
|
|
636
|
+
snapshot = existing;
|
|
637
|
+
}
|
|
638
|
+
/** @type {Snapshot["rows"][number][]} */
|
|
639
|
+
const rows = [];
|
|
640
|
+
// Signed cursor/envelope metadata is below 2 KiB. Count each row once;
|
|
641
|
+
// repeated serialization of a large discarded suffix would be quadratic.
|
|
642
|
+
let bytes = 2048;
|
|
643
|
+
for (const row of snapshot.rows.slice(offset, offset + limit)) {
|
|
644
|
+
const rowBytes = Buffer.byteLength(JSON.stringify(row)) + 1;
|
|
645
|
+
if (bytes + rowBytes > maximumBodyBytes) break;
|
|
646
|
+
bytes += rowBytes;
|
|
647
|
+
rows.push(row);
|
|
648
|
+
}
|
|
649
|
+
if (rows.length === 0 && offset < snapshot.rows.length)
|
|
650
|
+
throw new NativeLocalError(503, "capacity");
|
|
651
|
+
const next = offset + rows.length;
|
|
652
|
+
const continuation =
|
|
653
|
+
next < snapshot.rows.length
|
|
654
|
+
? {
|
|
655
|
+
[subscription ? "next_snapshot_cursor" : "next_cursor"]:
|
|
656
|
+
this.#cursor({
|
|
657
|
+
route,
|
|
658
|
+
predicate: predicateKey,
|
|
659
|
+
id,
|
|
660
|
+
offset: next,
|
|
661
|
+
expires: snapshot.expires,
|
|
662
|
+
}),
|
|
663
|
+
}
|
|
664
|
+
: subscription
|
|
665
|
+
? {
|
|
666
|
+
poll_cursor: this.#cursor({
|
|
667
|
+
route,
|
|
668
|
+
predicate: predicateKey,
|
|
669
|
+
sequence: snapshot.sequence,
|
|
670
|
+
expires: snapshot.expires,
|
|
671
|
+
}),
|
|
672
|
+
}
|
|
673
|
+
: {};
|
|
674
|
+
return jsonResponse({
|
|
675
|
+
version: 1,
|
|
676
|
+
...(subscription ? { phase: "snapshot" } : {}),
|
|
677
|
+
rows,
|
|
678
|
+
...continuation,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
class NativeLocalError extends Error {
|
|
684
|
+
/**
|
|
685
|
+
* @readonly
|
|
686
|
+
* @type {number}
|
|
687
|
+
*/
|
|
688
|
+
status;
|
|
689
|
+
/**
|
|
690
|
+
* @readonly
|
|
691
|
+
* @type {string}
|
|
692
|
+
*/
|
|
693
|
+
code;
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* @param {number} status
|
|
697
|
+
* @param {string} code
|
|
698
|
+
*/
|
|
699
|
+
constructor(status, code) {
|
|
700
|
+
super(code);
|
|
701
|
+
this.status = status;
|
|
702
|
+
this.code = code;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* @param {unknown} value
|
|
707
|
+
* @returns {JsonObject}
|
|
708
|
+
*/
|
|
709
|
+
function object(value) {
|
|
710
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
711
|
+
throw new TypeError("Expected object.");
|
|
712
|
+
return /** @type {JsonObject} */ (value);
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* @param {unknown} value
|
|
716
|
+
* @returns {string}
|
|
717
|
+
*/
|
|
718
|
+
function text(value) {
|
|
719
|
+
if (typeof value !== "string") throw new TypeError("Expected string.");
|
|
720
|
+
return value;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* @param {unknown} value
|
|
724
|
+
* @returns {bigint}
|
|
725
|
+
*/
|
|
726
|
+
function decimal(value) {
|
|
727
|
+
const input = text(value);
|
|
728
|
+
if (!/^(0|[1-9][0-9]*)$/u.test(input))
|
|
729
|
+
throw new TypeError("Expected decimal.");
|
|
730
|
+
return requireDatabaseU64(BigInt(input));
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* @param {JsonObject} value
|
|
734
|
+
* @param {readonly string[]} required
|
|
735
|
+
* @param {readonly string[]} [optional]
|
|
736
|
+
* @returns {void}
|
|
737
|
+
*/
|
|
738
|
+
function exactKeys(value, required, optional = []) {
|
|
739
|
+
if (
|
|
740
|
+
required.some((key) => !Object.hasOwn(value, key)) ||
|
|
741
|
+
Object.keys(value).some(
|
|
742
|
+
(key) => !required.includes(key) && !optional.includes(key),
|
|
743
|
+
)
|
|
744
|
+
)
|
|
745
|
+
throw new TypeError("Unexpected fields.");
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* @param {JsonObject} raw
|
|
749
|
+
* @returns {string}
|
|
750
|
+
*/
|
|
751
|
+
function transactionKey(raw) {
|
|
752
|
+
const id = object(raw.transaction_id);
|
|
753
|
+
return `${text(id.expires_at_unix_ms)}:${text(id.nonce)}`;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* @param {unknown} value
|
|
757
|
+
* @returns {readonly DatabaseField[]}
|
|
758
|
+
*/
|
|
759
|
+
function decodeFields(value) {
|
|
760
|
+
if (!Array.isArray(value)) throw new TypeError("Invalid fields.");
|
|
761
|
+
return value.map((raw) => {
|
|
762
|
+
const field = object(raw);
|
|
763
|
+
exactKeys(field, ["id", "value"]);
|
|
764
|
+
if (typeof field.id !== "number") throw new TypeError("Invalid field ID.");
|
|
765
|
+
return { id: field.id, value: decodeDatabaseValue(field.value) };
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* @param {JsonObject} raw
|
|
770
|
+
* @returns {DatabaseCommit}
|
|
771
|
+
*/
|
|
772
|
+
function decodeCommit(raw) {
|
|
773
|
+
exactKeys(raw, [
|
|
774
|
+
"version",
|
|
775
|
+
"transaction_id",
|
|
776
|
+
"authority",
|
|
777
|
+
"effect_digest",
|
|
778
|
+
"writes",
|
|
779
|
+
]);
|
|
780
|
+
if (raw.version !== 1 || !Array.isArray(raw.writes))
|
|
781
|
+
throw new TypeError("Invalid commit.");
|
|
782
|
+
const identity = object(raw.transaction_id);
|
|
783
|
+
exactKeys(identity, ["expires_at_unix_ms", "nonce"]);
|
|
784
|
+
return {
|
|
785
|
+
transaction_id: {
|
|
786
|
+
expires_at_unix_ms: decimal(identity.expires_at_unix_ms),
|
|
787
|
+
nonce: text(identity.nonce),
|
|
788
|
+
},
|
|
789
|
+
authority: decodeDatabaseAuthority(raw.authority),
|
|
790
|
+
writes: raw.writes.map((rawWrite) => {
|
|
791
|
+
const write = object(rawWrite);
|
|
792
|
+
const value = object(write.value);
|
|
793
|
+
exactKeys(write, [
|
|
794
|
+
"collection",
|
|
795
|
+
"schema_generation",
|
|
796
|
+
"schema_digest",
|
|
797
|
+
"document",
|
|
798
|
+
"condition",
|
|
799
|
+
"observed",
|
|
800
|
+
"value",
|
|
801
|
+
]);
|
|
802
|
+
const condition = object(write.condition);
|
|
803
|
+
exactKeys(
|
|
804
|
+
condition,
|
|
805
|
+
condition.kind === "version" ? ["kind", "version"] : ["kind"],
|
|
806
|
+
);
|
|
807
|
+
exactKeys(value, value.kind === "live" ? ["kind", "fields"] : ["kind"]);
|
|
808
|
+
const parsedCondition =
|
|
809
|
+
condition.kind === "version"
|
|
810
|
+
? { kind: "version", version: text(condition.version) }
|
|
811
|
+
: { kind: condition.kind };
|
|
812
|
+
if (!Array.isArray(write.observed))
|
|
813
|
+
throw new TypeError("Invalid observed.");
|
|
814
|
+
return {
|
|
815
|
+
collection: requireDatabaseUuid(write.collection),
|
|
816
|
+
schema_generation: decimal(write.schema_generation),
|
|
817
|
+
schema_digest: text(write.schema_digest),
|
|
818
|
+
document: requireDatabaseUuid(write.document),
|
|
819
|
+
condition: /** @type {DatabaseCondition} */ (parsedCondition),
|
|
820
|
+
observed: write.observed.map((rawObserved) => {
|
|
821
|
+
const observed = object(rawObserved);
|
|
822
|
+
exactKeys(observed, ["replica", "incarnation", "sequence"]);
|
|
823
|
+
return {
|
|
824
|
+
replica: text(observed.replica),
|
|
825
|
+
incarnation: text(observed.incarnation),
|
|
826
|
+
sequence: decimal(observed.sequence),
|
|
827
|
+
};
|
|
828
|
+
}),
|
|
829
|
+
value:
|
|
830
|
+
value.kind === "live"
|
|
831
|
+
? { kind: "live", fields: decodeFields(value.fields) }
|
|
832
|
+
: /** @type {DatabaseWrite["value"]} */ ({ kind: value.kind }),
|
|
833
|
+
};
|
|
834
|
+
}),
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* @param {DatabaseWrite["value"]} value
|
|
839
|
+
* @returns {JsonObject}
|
|
840
|
+
*/
|
|
841
|
+
function wireWriteValue(value) {
|
|
842
|
+
return value.kind === "tombstone"
|
|
843
|
+
? { kind: "tombstone" }
|
|
844
|
+
: {
|
|
845
|
+
kind: "live",
|
|
846
|
+
fields: value.fields.map((field) => ({
|
|
847
|
+
id: field.id,
|
|
848
|
+
value: encodeDatabaseValue(field.value),
|
|
849
|
+
})),
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* @param {DatabasePoint["siblings"][number]} sibling
|
|
854
|
+
* @returns {JsonObject}
|
|
855
|
+
*/
|
|
856
|
+
function wireSibling(sibling) {
|
|
857
|
+
return {
|
|
858
|
+
event: {
|
|
859
|
+
...sibling.event,
|
|
860
|
+
shard: sibling.event.shard.toString(),
|
|
861
|
+
sequence: sibling.event.sequence.toString(),
|
|
862
|
+
},
|
|
863
|
+
schema_generation: sibling.schema_generation.toString(),
|
|
864
|
+
schema_digest: sibling.schema_digest,
|
|
865
|
+
...wireWriteValue(sibling),
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* @param {unknown} raw
|
|
870
|
+
* @returns {DatabaseQueryPredicate}
|
|
871
|
+
*/
|
|
872
|
+
function decodePredicate(raw) {
|
|
873
|
+
const value = object(raw);
|
|
874
|
+
if (!Array.isArray(value.path)) throw new TypeError("Invalid path.");
|
|
875
|
+
const predicate = /** @type {DatabaseQueryPredicate} */ ({
|
|
876
|
+
...value,
|
|
877
|
+
...(value.value === undefined
|
|
878
|
+
? {}
|
|
879
|
+
: { value: decodeDatabaseValue(value.value) }),
|
|
880
|
+
});
|
|
881
|
+
encodeDatabaseQuery({ predicate, limit: 1 });
|
|
882
|
+
return predicate;
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* @param {WirePoint} raw
|
|
886
|
+
* @param {DatabaseQueryPredicate} predicate
|
|
887
|
+
* @returns {boolean}
|
|
888
|
+
*/
|
|
889
|
+
function matchesPoint(raw, predicate) {
|
|
890
|
+
const point = decodeDatabasePoint(raw);
|
|
891
|
+
return point.siblings.some((sibling) => {
|
|
892
|
+
if (sibling.kind !== "live") return false;
|
|
893
|
+
let fields = sibling.fields;
|
|
894
|
+
/** @type {DatabaseValue | undefined} */
|
|
895
|
+
let value;
|
|
896
|
+
for (const [index, id] of predicate.path.entries()) {
|
|
897
|
+
value = fields.find((field) => field.id === id)?.value;
|
|
898
|
+
if (index < predicate.path.length - 1) {
|
|
899
|
+
if (value?.type !== "map") {
|
|
900
|
+
value = undefined;
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
fields = value.fields;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (predicate.op === "is_missing") return value === undefined;
|
|
907
|
+
if (predicate.op === "is_null") return value?.type === "null";
|
|
908
|
+
if (!("value" in predicate)) return false;
|
|
909
|
+
if (
|
|
910
|
+
value === undefined ||
|
|
911
|
+
value.type !== predicate.value.type ||
|
|
912
|
+
!["bool", "i64", "utf8", "bytes"].includes(value.type)
|
|
913
|
+
)
|
|
914
|
+
return false;
|
|
915
|
+
const left = /** @type {Extract<DatabaseValue, { type: "bool" | "i64" | "utf8" | "bytes" }>} */ (
|
|
916
|
+
value
|
|
917
|
+
).value;
|
|
918
|
+
const right = predicate.value.value;
|
|
919
|
+
const comparison =
|
|
920
|
+
left instanceof Uint8Array && right instanceof Uint8Array
|
|
921
|
+
? Buffer.compare(left, right)
|
|
922
|
+
: left === right
|
|
923
|
+
? 0
|
|
924
|
+
: left < right
|
|
925
|
+
? -1
|
|
926
|
+
: 1;
|
|
927
|
+
switch (predicate.op) {
|
|
928
|
+
case "equal":
|
|
929
|
+
return comparison === 0;
|
|
930
|
+
case "less_than":
|
|
931
|
+
return comparison < 0;
|
|
932
|
+
case "less_than_or_equal":
|
|
933
|
+
return comparison <= 0;
|
|
934
|
+
case "greater_than":
|
|
935
|
+
return comparison > 0;
|
|
936
|
+
case "greater_than_or_equal":
|
|
937
|
+
return comparison >= 0;
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* @param {Request} request
|
|
943
|
+
* @returns {Promise<unknown>}
|
|
944
|
+
*/
|
|
945
|
+
async function readBody(request) {
|
|
946
|
+
const reader = request.body?.getReader();
|
|
947
|
+
if (reader === undefined) throw new TypeError("Missing body.");
|
|
948
|
+
/** @type {Uint8Array[]} */
|
|
949
|
+
const parts = [];
|
|
950
|
+
let size = 0;
|
|
951
|
+
try {
|
|
952
|
+
while (true) {
|
|
953
|
+
request.signal.throwIfAborted();
|
|
954
|
+
const chunk = await awaitAbortable(reader.read(), request.signal);
|
|
955
|
+
if (chunk.done) break;
|
|
956
|
+
size += chunk.value.byteLength;
|
|
957
|
+
if (size > maximumBodyBytes) throw new TypeError("Body limit exceeded.");
|
|
958
|
+
parts.push(chunk.value);
|
|
959
|
+
}
|
|
960
|
+
} catch (error) {
|
|
961
|
+
void reader.cancel().catch(() => undefined);
|
|
962
|
+
throw error;
|
|
963
|
+
} finally {
|
|
964
|
+
reader.releaseLock();
|
|
965
|
+
}
|
|
966
|
+
const bytes = new Uint8Array(size);
|
|
967
|
+
let offset = 0;
|
|
968
|
+
for (const part of parts) {
|
|
969
|
+
bytes.set(part, offset);
|
|
970
|
+
offset += part.byteLength;
|
|
971
|
+
}
|
|
972
|
+
return parseDatabaseJson(bytes);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* @template T
|
|
977
|
+
* @param {Promise<T>} work
|
|
978
|
+
* @param {AbortSignal} signal
|
|
979
|
+
* @returns {Promise<T>}
|
|
980
|
+
*/
|
|
981
|
+
async function awaitAbortable(work, signal) {
|
|
982
|
+
if (signal.aborted) {
|
|
983
|
+
void work.catch(() => undefined);
|
|
984
|
+
throw signal.reason;
|
|
985
|
+
}
|
|
986
|
+
/** @type {() => void} */
|
|
987
|
+
let onAbort = () => {};
|
|
988
|
+
/** @type {Promise<never>} */
|
|
989
|
+
const aborted = new Promise((_, reject) => {
|
|
990
|
+
onAbort = () => reject(signal.reason);
|
|
991
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
992
|
+
if (signal.aborted) onAbort();
|
|
993
|
+
});
|
|
994
|
+
try {
|
|
995
|
+
return await Promise.race([work, aborted]);
|
|
996
|
+
} finally {
|
|
997
|
+
signal.removeEventListener("abort", onAbort);
|
|
998
|
+
}
|
|
999
|
+
}
|