@tinycloud/sdk-services 3.0.0 → 3.1.0-beta.10
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/dist/{BaseService-BhpVpA9b.d.cts → BaseService-B_UKhVht.d.ts} +74 -2
- package/dist/{BaseService-CuV-ZVPs.d.ts → BaseService-DWbPbov7.d.cts} +74 -2
- package/dist/encryption/index.cjs +199 -100
- package/dist/encryption/index.cjs.map +1 -1
- package/dist/encryption/index.d.cts +2 -2
- package/dist/encryption/index.d.ts +2 -2
- package/dist/encryption/index.js +199 -100
- package/dist/encryption/index.js.map +1 -1
- package/dist/index.cjs +832 -310
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +84 -42
- package/dist/index.d.ts +84 -42
- package/dist/index.js +806 -292
- package/dist/index.js.map +1 -1
- package/dist/internal/decrypt-transport-response-error.d.cts +1 -1
- package/dist/internal/decrypt-transport-response-error.d.ts +1 -1
- package/dist/kv/index.cjs +623 -125
- package/dist/kv/index.cjs.map +1 -1
- package/dist/kv/index.d.cts +204 -14
- package/dist/kv/index.d.ts +204 -14
- package/dist/kv/index.js +623 -125
- package/dist/kv/index.js.map +1 -1
- package/dist/sql/index.cjs +191 -35
- package/dist/sql/index.cjs.map +1 -1
- package/dist/sql/index.d.cts +6 -3
- package/dist/sql/index.d.ts +6 -3
- package/dist/sql/index.js +182 -26
- package/dist/sql/index.js.map +1 -1
- package/dist/{types-qwMmB7qB.d.cts → types-CVwwxqXF.d.cts} +8 -0
- package/dist/{types-qwMmB7qB.d.ts → types-CVwwxqXF.d.ts} +8 -0
- package/package.json +3 -3
package/dist/kv/index.js
CHANGED
|
@@ -5,6 +5,10 @@ var ErrorCodes = {
|
|
|
5
5
|
AUTH_EXPIRED: "AUTH_EXPIRED",
|
|
6
6
|
AUTH_REQUIRED: "AUTH_REQUIRED",
|
|
7
7
|
AUTH_UNAUTHORIZED: "AUTH_UNAUTHORIZED",
|
|
8
|
+
/** HTTP 401: the delegation the invocation cites was revoked. */
|
|
9
|
+
AUTH_DELEGATION_REVOKED: "AUTH_DELEGATION_REVOKED",
|
|
10
|
+
/** HTTP 401: a delegation the cited one rests on was revoked. */
|
|
11
|
+
AUTH_DELEGATION_ANCESTOR_REVOKED: "AUTH_DELEGATION_ANCESTOR_REVOKED",
|
|
8
12
|
NETWORK_ERROR: "NETWORK_ERROR",
|
|
9
13
|
TIMEOUT: "TIMEOUT",
|
|
10
14
|
ABORTED: "ABORTED",
|
|
@@ -16,6 +20,10 @@ var ErrorCodes = {
|
|
|
16
20
|
KV_PRECONDITION_FAILED: "KV_PRECONDITION_FAILED",
|
|
17
21
|
KV_CONFLICT: "KV_CONFLICT",
|
|
18
22
|
KV_RESPONSE_TOO_LARGE: "KV_RESPONSE_TOO_LARGE",
|
|
23
|
+
/** HTTP 410 from the change feed: discard sync state and restart without a cursor; `meta.reason` says why. */
|
|
24
|
+
KV_SYNC_RESET_REQUIRED: "KV_SYNC_RESET_REQUIRED",
|
|
25
|
+
/** HTTP 403 from the change feed: the retention grant was refused; `meta.reason` says why. */
|
|
26
|
+
KV_RETENTION_GRANT_REFUSED: "KV_RETENTION_GRANT_REFUSED",
|
|
19
27
|
// SQL-specific errors
|
|
20
28
|
SQL_ERROR: "SQL_ERROR",
|
|
21
29
|
SQL_PERMISSION_DENIED: "SQL_PERMISSION_DENIED",
|
|
@@ -70,6 +78,55 @@ function serviceError(code, message, service, options) {
|
|
|
70
78
|
};
|
|
71
79
|
}
|
|
72
80
|
|
|
81
|
+
// src/errors.ts
|
|
82
|
+
import { CAPABILITY_REGISTRY } from "@tinycloud/bootstrap";
|
|
83
|
+
|
|
84
|
+
// src/encryption/networkId.ts
|
|
85
|
+
var URN_PREFIX = "urn:tinycloud:encryption:";
|
|
86
|
+
var NETWORK_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
87
|
+
var NetworkIdError = class extends Error {
|
|
88
|
+
constructor(message) {
|
|
89
|
+
super(message);
|
|
90
|
+
this.name = "NetworkIdError";
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
function parseNetworkId(networkId) {
|
|
94
|
+
if (typeof networkId !== "string" || networkId.length === 0) {
|
|
95
|
+
throw new NetworkIdError("networkId must be a non-empty string");
|
|
96
|
+
}
|
|
97
|
+
if (!networkId.startsWith(URN_PREFIX)) {
|
|
98
|
+
throw new NetworkIdError(
|
|
99
|
+
`networkId must start with ${URN_PREFIX} (got ${JSON.stringify(networkId)})`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const body = networkId.slice(URN_PREFIX.length);
|
|
103
|
+
const lastColon = body.lastIndexOf(":");
|
|
104
|
+
if (lastColon <= 0 || lastColon === body.length - 1) {
|
|
105
|
+
throw new NetworkIdError(
|
|
106
|
+
`networkId missing ownerDid or name segment (got ${JSON.stringify(networkId)})`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const ownerDid = body.slice(0, lastColon);
|
|
110
|
+
const name = body.slice(lastColon + 1);
|
|
111
|
+
if (!ownerDid.startsWith("did:")) {
|
|
112
|
+
throw new NetworkIdError(
|
|
113
|
+
`networkId ownerDid must be a DID (got ${JSON.stringify(ownerDid)})`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const didParts = ownerDid.split(":");
|
|
117
|
+
if (didParts.length < 3 || didParts.some((p) => p.length === 0)) {
|
|
118
|
+
throw new NetworkIdError(
|
|
119
|
+
`networkId ownerDid is not a well-formed DID (got ${JSON.stringify(ownerDid)})`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (!NETWORK_NAME_RE.test(name)) {
|
|
123
|
+
throw new NetworkIdError(
|
|
124
|
+
`networkId name ${JSON.stringify(name)} must match ${NETWORK_NAME_RE.source}`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return { networkId, ownerDid, name };
|
|
128
|
+
}
|
|
129
|
+
|
|
73
130
|
// src/errors.ts
|
|
74
131
|
function parsePermissionHint(value) {
|
|
75
132
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
@@ -114,11 +171,31 @@ function authRequiredError(service) {
|
|
|
114
171
|
service
|
|
115
172
|
};
|
|
116
173
|
}
|
|
117
|
-
|
|
174
|
+
var RequestTimeoutError = class extends Error {
|
|
175
|
+
constructor(timeoutMs) {
|
|
176
|
+
super(`Request timed out after ${timeoutMs}ms.`);
|
|
177
|
+
this.name = "TimeoutError";
|
|
178
|
+
this.timeoutMs = timeoutMs;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
function isRequestTimeoutError(error) {
|
|
182
|
+
return error instanceof RequestTimeoutError || error instanceof Error && error.name === "TimeoutError" && typeof error.timeoutMs === "number";
|
|
183
|
+
}
|
|
184
|
+
function timeoutError(service, timeoutMs, cause) {
|
|
185
|
+
if (timeoutMs === void 0) {
|
|
186
|
+
return {
|
|
187
|
+
code: ErrorCodes.TIMEOUT,
|
|
188
|
+
message: "Request timed out.",
|
|
189
|
+
service,
|
|
190
|
+
...cause === void 0 ? {} : { cause }
|
|
191
|
+
};
|
|
192
|
+
}
|
|
118
193
|
return {
|
|
119
194
|
code: ErrorCodes.TIMEOUT,
|
|
120
|
-
message:
|
|
121
|
-
service
|
|
195
|
+
message: `Request timed out after ${timeoutMs}ms.`,
|
|
196
|
+
service,
|
|
197
|
+
...cause === void 0 ? {} : { cause },
|
|
198
|
+
meta: { timeoutMs }
|
|
122
199
|
};
|
|
123
200
|
}
|
|
124
201
|
function abortedError(service) {
|
|
@@ -138,6 +215,101 @@ function parseAuthError(responseText) {
|
|
|
138
215
|
function authUnauthorizedError(service, message, meta) {
|
|
139
216
|
return serviceError(ErrorCodes.AUTH_UNAUTHORIZED, message, service, { meta });
|
|
140
217
|
}
|
|
218
|
+
var grantableActions = new Set(
|
|
219
|
+
CAPABILITY_REGISTRY.map(({ urn }) => urn).filter((urn) => !urn.endsWith("/*"))
|
|
220
|
+
);
|
|
221
|
+
var capabilityServices = new Set(
|
|
222
|
+
CAPABILITY_REGISTRY.map(({ service }) => service.slice("tinycloud.".length))
|
|
223
|
+
);
|
|
224
|
+
function parseCapabilityResource(resource, service) {
|
|
225
|
+
if (resource.length === 0 || resource.length > 1024 || !capabilityServices.has(service) || /[\s\p{C}*#]/u.test(resource) || resource.includes("://")) return void 0;
|
|
226
|
+
const segments = resource.split("/");
|
|
227
|
+
if (!/^tinycloud:[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(segments[0])) return void 0;
|
|
228
|
+
const serviceIndex = segments.findIndex(
|
|
229
|
+
(segment, index) => index > 0 && capabilityServices.has(segment)
|
|
230
|
+
);
|
|
231
|
+
if (serviceIndex < 1 || segments[serviceIndex] !== service) return void 0;
|
|
232
|
+
if (segments.slice(1, serviceIndex).some(
|
|
233
|
+
(segment) => segment.length === 0 || segment === "." || segment === ".."
|
|
234
|
+
)) return void 0;
|
|
235
|
+
const pathSegments = segments.slice(serviceIndex + 1);
|
|
236
|
+
if (pathSegments.some((segment) => segment === "." || segment === "..")) return void 0;
|
|
237
|
+
if (service !== "kv" && (pathSegments.length === 0 || pathSegments[pathSegments.length - 1] === "")) return void 0;
|
|
238
|
+
return {
|
|
239
|
+
space: segments.slice(0, serviceIndex).join("/"),
|
|
240
|
+
path: pathSegments.join("/")
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function safeCapabilityResource(value, service, action) {
|
|
244
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 1024) return false;
|
|
245
|
+
if (service === "encryption" && value.startsWith("urn:tinycloud:encryption:")) {
|
|
246
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_.:/-]*$/.test(value) || value.includes("//") || value.split("/").some((segment) => segment === "." || segment === "..")) return false;
|
|
247
|
+
try {
|
|
248
|
+
parseNetworkId(value);
|
|
249
|
+
return true;
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const parsed = parseCapabilityResource(value, service);
|
|
255
|
+
return parsed !== void 0 && (!parsed.path.endsWith("/") || service === "kv" && action === "tinycloud.kv/list") && (parsed.path !== "" || service === "kv" && action === "tinycloud.kv/list");
|
|
256
|
+
}
|
|
257
|
+
function validatedCapabilityOf(error) {
|
|
258
|
+
const verdict = authorizationVerdictOf(error);
|
|
259
|
+
if (verdict !== "unauthenticated" && verdict !== "forbidden") return void 0;
|
|
260
|
+
const seen = /* @__PURE__ */ new Set();
|
|
261
|
+
let node = error;
|
|
262
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof node === "object" && node !== null && !seen.has(node); depth += 1) {
|
|
263
|
+
seen.add(node);
|
|
264
|
+
const record = node;
|
|
265
|
+
const status = errorStatusOf(record);
|
|
266
|
+
const meta = record.meta;
|
|
267
|
+
if ((status === 401 || status === 403 || status === void 0 && record.code === ErrorCodes.AUTH_UNAUTHORIZED) && typeof meta === "object" && meta !== null && !Array.isArray(meta)) {
|
|
268
|
+
const metadata = meta;
|
|
269
|
+
const { resource, requiredAction } = metadata;
|
|
270
|
+
if (typeof requiredAction === "string" && grantableActions.has(requiredAction)) {
|
|
271
|
+
const actionService = requiredAction.slice("tinycloud.".length, requiredAction.indexOf("/"));
|
|
272
|
+
const explicitService = record.service === void 0 ? metadata.service : record.service;
|
|
273
|
+
if (safeCapabilityResource(resource, actionService, requiredAction) && (explicitService === void 0 || explicitService === actionService || explicitService === `tinycloud.${actionService}`)) {
|
|
274
|
+
return { resource, requiredAction };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
node = record.cause;
|
|
279
|
+
}
|
|
280
|
+
return void 0;
|
|
281
|
+
}
|
|
282
|
+
var MAX_CAUSE_DEPTH = 8;
|
|
283
|
+
function errorStatusOf(node) {
|
|
284
|
+
const meta = node.meta;
|
|
285
|
+
const candidates = [
|
|
286
|
+
node.status,
|
|
287
|
+
node.statusCode,
|
|
288
|
+
typeof meta === "object" && meta !== null ? meta.status : void 0
|
|
289
|
+
];
|
|
290
|
+
for (const candidate of candidates) {
|
|
291
|
+
if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 400 && candidate <= 599) {
|
|
292
|
+
return candidate;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return void 0;
|
|
296
|
+
}
|
|
297
|
+
function authorizationVerdictOf(error) {
|
|
298
|
+
const seen = /* @__PURE__ */ new Set();
|
|
299
|
+
let sawUnauthorizedCode = false;
|
|
300
|
+
let node = error;
|
|
301
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof node === "object" && node !== null && !seen.has(node); depth += 1) {
|
|
302
|
+
seen.add(node);
|
|
303
|
+
const record = node;
|
|
304
|
+
const status = errorStatusOf(record);
|
|
305
|
+
if (status === 401) return "unauthenticated";
|
|
306
|
+
if (status === 403) return "forbidden";
|
|
307
|
+
if (status !== void 0) return "other";
|
|
308
|
+
if (record.code === ErrorCodes.AUTH_UNAUTHORIZED) sawUnauthorizedCode = true;
|
|
309
|
+
node = record.cause;
|
|
310
|
+
}
|
|
311
|
+
return sawUnauthorizedCode ? "forbidden" : void 0;
|
|
312
|
+
}
|
|
141
313
|
function storageQuotaExceededError(service, message, meta) {
|
|
142
314
|
return {
|
|
143
315
|
code: ErrorCodes.STORAGE_QUOTA_EXCEEDED,
|
|
@@ -154,8 +326,25 @@ function storageLimitReachedError(service, message, meta) {
|
|
|
154
326
|
meta
|
|
155
327
|
};
|
|
156
328
|
}
|
|
329
|
+
var STORAGE_FULL_MESSAGE = "TinyCloud storage is full, so this change was not saved. Reading still works. Free up space or upgrade your plan to save again.";
|
|
330
|
+
var STORAGE_WRITE_TOO_LARGE_MESSAGE = "This change is larger than the TinyCloud storage you have left, so it was not saved. Reading still works. Free up space or upgrade your plan to save it.";
|
|
331
|
+
function parseStorageQuotaBytes(responseText) {
|
|
332
|
+
const match = responseText.match(/Used:\s*(\d+)\s*bytes,\s*Limit:\s*(\d+)\s*bytes/i);
|
|
333
|
+
if (!match) return void 0;
|
|
334
|
+
return {
|
|
335
|
+
usedBytes: parseInt(match[1], 10),
|
|
336
|
+
limitBytes: parseInt(match[2], 10)
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function storageRejectionError(service, status, meta, responseText) {
|
|
340
|
+
const quotaMeta = { ...meta, ...parseStorageQuotaBytes(responseText) };
|
|
341
|
+
return status === 402 ? storageQuotaExceededError(service, STORAGE_FULL_MESSAGE, quotaMeta) : storageLimitReachedError(service, STORAGE_WRITE_TOO_LARGE_MESSAGE, quotaMeta);
|
|
342
|
+
}
|
|
157
343
|
function wrapError(service, error, defaultCode = ErrorCodes.NETWORK_ERROR) {
|
|
158
344
|
if (error instanceof Error) {
|
|
345
|
+
if (isRequestTimeoutError(error)) {
|
|
346
|
+
return timeoutError(service, error.timeoutMs, error);
|
|
347
|
+
}
|
|
159
348
|
if (error.name === "AbortError") {
|
|
160
349
|
return abortedError(service);
|
|
161
350
|
}
|
|
@@ -177,6 +366,7 @@ function wrapError(service, error, defaultCode = ErrorCodes.NETWORK_ERROR) {
|
|
|
177
366
|
}
|
|
178
367
|
|
|
179
368
|
// src/base/BaseService.ts
|
|
369
|
+
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
180
370
|
var BaseService = class {
|
|
181
371
|
constructor() {
|
|
182
372
|
/**
|
|
@@ -357,6 +547,64 @@ var BaseService = class {
|
|
|
357
547
|
}
|
|
358
548
|
return controller.signal;
|
|
359
549
|
}
|
|
550
|
+
/**
|
|
551
|
+
* Create the abort signal for one request, bounded by the request timeout.
|
|
552
|
+
*
|
|
553
|
+
* The signal aborts when the service signs out, the context aborts, the
|
|
554
|
+
* caller's `signal` aborts, or the timeout elapses, whichever comes first.
|
|
555
|
+
* A timeout aborts with a {@link RequestTimeoutError} (`TimeoutError`), which
|
|
556
|
+
* `wrapError` maps to `ErrorCodes.TIMEOUT`; the other sources keep their own
|
|
557
|
+
* reason (normally `AbortError`, mapped to `ErrorCodes.ABORTED`).
|
|
558
|
+
*
|
|
559
|
+
* The timeout is `timeoutMs` when given, otherwise `config.timeout`. Only a
|
|
560
|
+
* positive, finite value (at most 2^31-1 ms) applies; `undefined`, `0`, and
|
|
561
|
+
* `Infinity` mean no timeout, so a per-call `0` opts out of a configured one.
|
|
562
|
+
*
|
|
563
|
+
* Callers must call `dispose()` once the response body has been consumed,
|
|
564
|
+
* which clears the timer and removes the listeners this adds to the
|
|
565
|
+
* long-lived service and context signals.
|
|
566
|
+
*
|
|
567
|
+
* @param signal - The caller's abort signal for this request
|
|
568
|
+
* @param timeoutMs - Per-request timeout override in milliseconds
|
|
569
|
+
*/
|
|
570
|
+
createRequestSignal(signal, timeoutMs) {
|
|
571
|
+
const controller = new AbortController();
|
|
572
|
+
const detachers = [];
|
|
573
|
+
let timer;
|
|
574
|
+
const dispose = () => {
|
|
575
|
+
if (timer !== void 0) {
|
|
576
|
+
clearTimeout(timer);
|
|
577
|
+
timer = void 0;
|
|
578
|
+
}
|
|
579
|
+
for (const detach of detachers.splice(0)) {
|
|
580
|
+
detach();
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
const abort = (reason) => {
|
|
584
|
+
dispose();
|
|
585
|
+
controller.abort(reason);
|
|
586
|
+
};
|
|
587
|
+
const requestSignal = { signal: controller.signal, dispose };
|
|
588
|
+
const parents = [
|
|
589
|
+
this.abortController.signal,
|
|
590
|
+
this.context?.abortSignal,
|
|
591
|
+
signal
|
|
592
|
+
].filter((parent) => parent !== void 0);
|
|
593
|
+
for (const parent of parents) {
|
|
594
|
+
if (parent.aborted) {
|
|
595
|
+
abort(parent.reason);
|
|
596
|
+
return requestSignal;
|
|
597
|
+
}
|
|
598
|
+
const onAbort = () => abort(parent.reason);
|
|
599
|
+
parent.addEventListener("abort", onAbort, { once: true });
|
|
600
|
+
detachers.push(() => parent.removeEventListener("abort", onAbort));
|
|
601
|
+
}
|
|
602
|
+
const timeout = timeoutMs ?? this._config.timeout;
|
|
603
|
+
if (typeof timeout === "number" && timeout > 0 && timeout <= MAX_TIMER_DELAY_MS) {
|
|
604
|
+
timer = setTimeout(() => abort(new RequestTimeoutError(timeout)), timeout);
|
|
605
|
+
}
|
|
606
|
+
return requestSignal;
|
|
607
|
+
}
|
|
360
608
|
/**
|
|
361
609
|
* Wrap an operation with error handling and telemetry.
|
|
362
610
|
*
|
|
@@ -465,6 +713,30 @@ var PrefixedKVService = class _PrefixedKVService {
|
|
|
465
713
|
removePrefix
|
|
466
714
|
});
|
|
467
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* Read the change feed for everything under this prefix. A view with an
|
|
718
|
+
* empty prefix (`""` or `"/"`) is refused: the node does not serve a
|
|
719
|
+
* whole-space feed.
|
|
720
|
+
*/
|
|
721
|
+
async changes(options) {
|
|
722
|
+
if (this._prefix.replace(/\/+$/, "") === "") {
|
|
723
|
+
return err(serviceError(
|
|
724
|
+
ErrorCodes.INVALID_INPUT,
|
|
725
|
+
"KV changes needs a non-empty prefix; this view has none",
|
|
726
|
+
"kv"
|
|
727
|
+
));
|
|
728
|
+
}
|
|
729
|
+
const syncPrefix = `${this._prefix}/`;
|
|
730
|
+
const response = await this._kv.changes({ ...options, prefix: syncPrefix });
|
|
731
|
+
if (!response.ok) return response;
|
|
732
|
+
return ok({
|
|
733
|
+
...response.data,
|
|
734
|
+
changes: response.data.changes.map((change) => ({
|
|
735
|
+
...change,
|
|
736
|
+
key: change.key.startsWith(syncPrefix) ? change.key.slice(syncPrefix.length) : change.key
|
|
737
|
+
}))
|
|
738
|
+
});
|
|
739
|
+
}
|
|
468
740
|
/**
|
|
469
741
|
* Delete a key.
|
|
470
742
|
*/
|
|
@@ -517,11 +789,24 @@ var KVAction = {
|
|
|
517
789
|
PUT: "tinycloud.kv/put",
|
|
518
790
|
LIST: "tinycloud.kv/list",
|
|
519
791
|
DELETE: "tinycloud.kv/del",
|
|
520
|
-
HEAD: "tinycloud.kv/metadata"
|
|
792
|
+
HEAD: "tinycloud.kv/metadata",
|
|
793
|
+
/** The change feed; never implied by `*` or `tinycloud.kv/*`. */
|
|
794
|
+
SYNC: "tinycloud.kv/sync",
|
|
795
|
+
/** Retention attestation; presented through `retentionGrant`, never invoked or implied. */
|
|
796
|
+
RETAIN: "tinycloud.kv/retain"
|
|
521
797
|
};
|
|
522
798
|
|
|
523
799
|
// src/kv/KVService.ts
|
|
524
800
|
var MAX_KV_BATCH_READ_ITEMS = 100;
|
|
801
|
+
function isJsonObject(value) {
|
|
802
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
803
|
+
}
|
|
804
|
+
var REVOKED_BODY = /^(?:Invalid invocation: )?delegation-(?:(revoked): [A-Za-z0-9]+|(ancestor-revoked): ancestor=[A-Za-z0-9]+ invoked=[A-Za-z0-9]+)$/;
|
|
805
|
+
function revocationCodeOf(body) {
|
|
806
|
+
const match = REVOKED_BODY.exec(body.trim());
|
|
807
|
+
if (match === null) return void 0;
|
|
808
|
+
return match[1] !== void 0 ? ErrorCodes.AUTH_DELEGATION_REVOKED : ErrorCodes.AUTH_DELEGATION_ANCESTOR_REVOKED;
|
|
809
|
+
}
|
|
525
810
|
function encodeKvBatchPartName(path) {
|
|
526
811
|
return encodeURIComponent(path).replace(
|
|
527
812
|
/[!'()*]/g,
|
|
@@ -544,47 +829,21 @@ var KVService = class extends BaseService {
|
|
|
544
829
|
get config() {
|
|
545
830
|
return this._config;
|
|
546
831
|
}
|
|
547
|
-
// Parses "Used: X bytes, Limit: Y bytes" from tinycloud-node error responses
|
|
548
|
-
parseQuotaInfo(errorText) {
|
|
549
|
-
const match = errorText.match(
|
|
550
|
-
/Used:\s*(\d+)\s*bytes,\s*Limit:\s*(\d+)\s*bytes/i
|
|
551
|
-
);
|
|
552
|
-
if (match) {
|
|
553
|
-
return {
|
|
554
|
-
usedBytes: parseInt(match[1], 10),
|
|
555
|
-
limitBytes: parseInt(match[2], 10)
|
|
556
|
-
};
|
|
557
|
-
}
|
|
558
|
-
return void 0;
|
|
559
|
-
}
|
|
560
832
|
handleQuotaErrorResponse(response, errorText, key) {
|
|
561
|
-
if (response.status
|
|
562
|
-
|
|
563
|
-
return err(
|
|
564
|
-
storageQuotaExceededError(
|
|
565
|
-
"kv",
|
|
566
|
-
`Storage quota exceeded for key "${key}": ${errorText}`,
|
|
567
|
-
{
|
|
568
|
-
status: response.status,
|
|
569
|
-
...quotaInfo ? { usedBytes: quotaInfo.usedBytes, limitBytes: quotaInfo.limitBytes } : {}
|
|
570
|
-
}
|
|
571
|
-
)
|
|
572
|
-
);
|
|
833
|
+
if (response.status !== 402 && response.status !== 413) {
|
|
834
|
+
return void 0;
|
|
573
835
|
}
|
|
574
|
-
if (response.status === 413) {
|
|
575
|
-
|
|
576
|
-
return err(
|
|
577
|
-
storageLimitReachedError(
|
|
578
|
-
"kv",
|
|
579
|
-
`Storage limit reached for key "${key}": ${errorText}`,
|
|
580
|
-
{
|
|
581
|
-
status: response.status,
|
|
582
|
-
...quotaInfo ? { usedBytes: quotaInfo.usedBytes, limitBytes: quotaInfo.limitBytes } : {}
|
|
583
|
-
}
|
|
584
|
-
)
|
|
585
|
-
);
|
|
836
|
+
if (response.status === 413 && !parseStorageQuotaBytes(errorText)) {
|
|
837
|
+
return void 0;
|
|
586
838
|
}
|
|
587
|
-
return
|
|
839
|
+
return err(
|
|
840
|
+
storageRejectionError(
|
|
841
|
+
"kv",
|
|
842
|
+
response.status,
|
|
843
|
+
{ status: response.status, statusText: response.statusText, key },
|
|
844
|
+
errorText
|
|
845
|
+
)
|
|
846
|
+
);
|
|
588
847
|
}
|
|
589
848
|
/**
|
|
590
849
|
* Classify a KV 404 by reading the response body once.
|
|
@@ -643,7 +902,7 @@ var KVService = class extends BaseService {
|
|
|
643
902
|
* @param path - Resource path
|
|
644
903
|
* @param action - KV action
|
|
645
904
|
* @param body - Optional request body
|
|
646
|
-
* @param signal -
|
|
905
|
+
* @param signal - Request signal from createRequestSignal()
|
|
647
906
|
* @returns Fetch response
|
|
648
907
|
*/
|
|
649
908
|
async invokeOperation(path, action, body, signal, extraHeaders) {
|
|
@@ -659,7 +918,7 @@ var KVService = class extends BaseService {
|
|
|
659
918
|
method: "POST",
|
|
660
919
|
headers: requestHeaders,
|
|
661
920
|
body,
|
|
662
|
-
signal
|
|
921
|
+
signal
|
|
663
922
|
});
|
|
664
923
|
}
|
|
665
924
|
/**
|
|
@@ -836,6 +1095,7 @@ var KVService = class extends BaseService {
|
|
|
836
1095
|
"kv"
|
|
837
1096
|
));
|
|
838
1097
|
}
|
|
1098
|
+
const request = this.createRequestSignal(options?.signal, options?.timeout);
|
|
839
1099
|
try {
|
|
840
1100
|
const session = this.context.session;
|
|
841
1101
|
const invocationHeaders = this.context.invokeAny(
|
|
@@ -857,17 +1117,18 @@ var KVService = class extends BaseService {
|
|
|
857
1117
|
const response = await this.context.fetch(`${this.host}/invoke`, {
|
|
858
1118
|
method: "POST",
|
|
859
1119
|
headers,
|
|
860
|
-
signal:
|
|
1120
|
+
signal: request.signal
|
|
861
1121
|
});
|
|
862
1122
|
if (!response.ok) {
|
|
863
|
-
const errorText = await response.text();
|
|
1123
|
+
const errorText = response.status === 401 || response.status === 403 ? await this.readAuthorizationText(response) : await response.text();
|
|
864
1124
|
if (response.status === 401 || response.status === 403) {
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1125
|
+
return this.authorizationFailure(
|
|
1126
|
+
`Failed to batch read ${keys.length} key(s)`,
|
|
1127
|
+
response,
|
|
1128
|
+
errorText,
|
|
1129
|
+
paths,
|
|
1130
|
+
action
|
|
1131
|
+
);
|
|
871
1132
|
}
|
|
872
1133
|
if (response.status === 413) {
|
|
873
1134
|
return err(serviceError(
|
|
@@ -921,6 +1182,8 @@ var KVService = class extends BaseService {
|
|
|
921
1182
|
return ok({ results, count: results.length });
|
|
922
1183
|
} catch (error) {
|
|
923
1184
|
return err(wrapError("kv", error));
|
|
1185
|
+
} finally {
|
|
1186
|
+
request.dispose();
|
|
924
1187
|
}
|
|
925
1188
|
}
|
|
926
1189
|
/**
|
|
@@ -956,7 +1219,7 @@ var KVService = class extends BaseService {
|
|
|
956
1219
|
return text;
|
|
957
1220
|
}
|
|
958
1221
|
}
|
|
959
|
-
async createSignedReadUrlError(response, key) {
|
|
1222
|
+
async createSignedReadUrlError(response, key, path) {
|
|
960
1223
|
let errorText = response.statusText;
|
|
961
1224
|
try {
|
|
962
1225
|
const text = await response.text();
|
|
@@ -966,23 +1229,62 @@ var KVService = class extends BaseService {
|
|
|
966
1229
|
} catch {
|
|
967
1230
|
}
|
|
968
1231
|
if (response.status === 401 || response.status === 403) {
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1232
|
+
return this.authorizationFailure(
|
|
1233
|
+
`Failed to create signed read URL for key ${JSON.stringify(key)}`,
|
|
1234
|
+
response,
|
|
1235
|
+
errorText,
|
|
1236
|
+
[path],
|
|
1237
|
+
KVAction.GET
|
|
1238
|
+
);
|
|
975
1239
|
}
|
|
976
1240
|
const code = response.status === 400 ? ErrorCodes.INVALID_INPUT : ErrorCodes.NETWORK_ERROR;
|
|
977
1241
|
return err(
|
|
978
1242
|
serviceError(
|
|
979
1243
|
code,
|
|
980
|
-
`Failed to create signed read URL for key
|
|
1244
|
+
`Failed to create signed read URL for key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
|
|
981
1245
|
"kv",
|
|
982
1246
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
983
1247
|
)
|
|
984
1248
|
);
|
|
985
1249
|
}
|
|
1250
|
+
/** A known 401/403 keeps its typed status even if reading its body fails. */
|
|
1251
|
+
async readAuthorizationText(response) {
|
|
1252
|
+
try {
|
|
1253
|
+
return await response.text();
|
|
1254
|
+
} catch {
|
|
1255
|
+
return "";
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* AUTH_UNAUTHORIZED for a 401/403 response. The message keeps the HTTP
|
|
1260
|
+
* status and the server text (`<context>: <status> - <text>`, the same
|
|
1261
|
+
* shape as every other KV failure) so callers that rethrow only the message
|
|
1262
|
+
* still see the status; `meta.status` carries it for typed callers.
|
|
1263
|
+
* Server hints must match the requested path/action (whether the node sends
|
|
1264
|
+
* a relative path or full resource); metadata uses only the request-derived
|
|
1265
|
+
* canonical resource under the authenticated session's space.
|
|
1266
|
+
*/
|
|
1267
|
+
authorizationFailure(context, response, errorText, paths, expectedAction, extraMeta = {}) {
|
|
1268
|
+
const { resource, action } = parseAuthError(errorText);
|
|
1269
|
+
const requestedPath = paths.find(
|
|
1270
|
+
(path) => resource === path || resource === `${this.context.session.spaceId}/kv/${path}`
|
|
1271
|
+
);
|
|
1272
|
+
const capability = requestedPath !== void 0 && action === expectedAction ? validatedCapabilityOf({
|
|
1273
|
+
service: "kv",
|
|
1274
|
+
code: ErrorCodes.AUTH_UNAUTHORIZED,
|
|
1275
|
+
meta: {
|
|
1276
|
+
status: response.status,
|
|
1277
|
+
resource: `${this.context.session.spaceId}/kv/${requestedPath}`,
|
|
1278
|
+
requiredAction: action
|
|
1279
|
+
}
|
|
1280
|
+
}) : void 0;
|
|
1281
|
+
const detail = errorText.trim().length > 0 ? errorText : response.statusText || "authorization failed";
|
|
1282
|
+
return err(authUnauthorizedError("kv", `${context}: ${response.status} - ${detail}`, {
|
|
1283
|
+
status: response.status,
|
|
1284
|
+
...extraMeta,
|
|
1285
|
+
...capability
|
|
1286
|
+
}));
|
|
1287
|
+
}
|
|
986
1288
|
normalizeSignedReadUrlResponse(data) {
|
|
987
1289
|
if (!data || typeof data !== "object") {
|
|
988
1290
|
return void 0;
|
|
@@ -1014,25 +1316,36 @@ var KVService = class extends BaseService {
|
|
|
1014
1316
|
));
|
|
1015
1317
|
}
|
|
1016
1318
|
const path = this.getFullPath(key, options?.prefix);
|
|
1319
|
+
const request = this.createRequestSignal(options?.signal, options?.timeout);
|
|
1017
1320
|
try {
|
|
1018
1321
|
const response = await this.invokeOperation(
|
|
1019
1322
|
path,
|
|
1020
1323
|
KVAction.GET,
|
|
1021
1324
|
void 0,
|
|
1022
|
-
|
|
1325
|
+
request.signal,
|
|
1023
1326
|
options?.maxResponseBytes === void 0 ? void 0 : { "x-tinycloud-max-response-bytes": String(options.maxResponseBytes) }
|
|
1024
1327
|
);
|
|
1025
1328
|
if (!response.ok) {
|
|
1026
1329
|
if (response.status === 401 || response.status === 403) {
|
|
1027
|
-
const errorText2 = await
|
|
1028
|
-
const { resource, action } = parseAuthError(errorText2);
|
|
1330
|
+
const errorText2 = await this.readAuthorizationText(response);
|
|
1029
1331
|
const permissionHint = parsePermissionHintFromErrorText(errorText2);
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1332
|
+
const structuredCapability = permissionHint?.service === "tinycloud.kv" && permissionHint.space === this.context.session.spaceId && permissionHint.path === path ? validatedCapabilityOf({
|
|
1333
|
+
service: "kv",
|
|
1334
|
+
code: ErrorCodes.AUTH_UNAUTHORIZED,
|
|
1335
|
+
meta: {
|
|
1336
|
+
status: response.status,
|
|
1337
|
+
resource: `${this.context.session.spaceId}/kv/${path}`,
|
|
1338
|
+
requiredAction: KVAction.GET
|
|
1339
|
+
}
|
|
1340
|
+
}) : void 0;
|
|
1341
|
+
return this.authorizationFailure(
|
|
1342
|
+
`Failed to get key ${JSON.stringify(key)}`,
|
|
1343
|
+
response,
|
|
1344
|
+
errorText2,
|
|
1345
|
+
[path],
|
|
1346
|
+
KVAction.GET,
|
|
1347
|
+
structuredCapability ? { permissionHint, ...structuredCapability } : {}
|
|
1348
|
+
);
|
|
1036
1349
|
}
|
|
1037
1350
|
if (response.status === 404) {
|
|
1038
1351
|
return this.classifyNotFound(response, key);
|
|
@@ -1041,7 +1354,7 @@ var KVService = class extends BaseService {
|
|
|
1041
1354
|
if (response.status === 413) {
|
|
1042
1355
|
return err(serviceError(
|
|
1043
1356
|
ErrorCodes.KV_RESPONSE_TOO_LARGE,
|
|
1044
|
-
`KV value at key
|
|
1357
|
+
`KV value at key ${JSON.stringify(key)} exceeds the requested response limit`,
|
|
1045
1358
|
"kv",
|
|
1046
1359
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1047
1360
|
));
|
|
@@ -1049,7 +1362,7 @@ var KVService = class extends BaseService {
|
|
|
1049
1362
|
return err(
|
|
1050
1363
|
serviceError(
|
|
1051
1364
|
ErrorCodes.NETWORK_ERROR,
|
|
1052
|
-
`Failed to get key
|
|
1365
|
+
`Failed to get key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
|
|
1053
1366
|
"kv",
|
|
1054
1367
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1055
1368
|
)
|
|
@@ -1066,6 +1379,8 @@ var KVService = class extends BaseService {
|
|
|
1066
1379
|
});
|
|
1067
1380
|
} catch (error) {
|
|
1068
1381
|
return err(wrapError("kv", error));
|
|
1382
|
+
} finally {
|
|
1383
|
+
request.dispose();
|
|
1069
1384
|
}
|
|
1070
1385
|
});
|
|
1071
1386
|
}
|
|
@@ -1093,33 +1408,33 @@ var KVService = class extends BaseService {
|
|
|
1093
1408
|
}
|
|
1094
1409
|
const path = this.getFullPath(key, options?.prefix);
|
|
1095
1410
|
const body = this.serializePutValue(value, options?.contentType);
|
|
1411
|
+
const request = this.createRequestSignal(options?.signal, options?.timeout);
|
|
1096
1412
|
try {
|
|
1097
1413
|
const response = await this.invokeOperation(
|
|
1098
1414
|
path,
|
|
1099
1415
|
KVAction.PUT,
|
|
1100
1416
|
body,
|
|
1101
|
-
|
|
1417
|
+
request.signal,
|
|
1102
1418
|
{
|
|
1103
1419
|
...options?.ifMatch === void 0 ? {} : { "if-match": options.ifMatch },
|
|
1104
1420
|
...options?.ifNoneMatch === void 0 ? {} : { "if-none-match": options.ifNoneMatch }
|
|
1105
1421
|
}
|
|
1106
1422
|
);
|
|
1107
1423
|
if (response.status === 401 || response.status === 403) {
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
}));
|
|
1424
|
+
return this.authorizationFailure(
|
|
1425
|
+
`Failed to put key ${JSON.stringify(key)}`,
|
|
1426
|
+
response,
|
|
1427
|
+
await this.readAuthorizationText(response),
|
|
1428
|
+
[path],
|
|
1429
|
+
KVAction.PUT
|
|
1430
|
+
);
|
|
1116
1431
|
}
|
|
1117
1432
|
if (!response.ok) {
|
|
1118
1433
|
const errorText = await response.text();
|
|
1119
1434
|
if (response.status === 412) {
|
|
1120
1435
|
return err(serviceError(
|
|
1121
1436
|
ErrorCodes.KV_PRECONDITION_FAILED,
|
|
1122
|
-
`KV precondition failed for key
|
|
1437
|
+
`KV precondition failed for key ${JSON.stringify(key)}`,
|
|
1123
1438
|
"kv",
|
|
1124
1439
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1125
1440
|
));
|
|
@@ -1127,7 +1442,7 @@ var KVService = class extends BaseService {
|
|
|
1127
1442
|
if (response.status === 503 && (options?.ifMatch !== void 0 || options?.ifNoneMatch !== void 0)) {
|
|
1128
1443
|
return err(serviceError(
|
|
1129
1444
|
ErrorCodes.KV_CONFLICT,
|
|
1130
|
-
`Concurrent KV update conflicted for key
|
|
1445
|
+
`Concurrent KV update conflicted for key ${JSON.stringify(key)}`,
|
|
1131
1446
|
"kv",
|
|
1132
1447
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1133
1448
|
));
|
|
@@ -1143,7 +1458,7 @@ var KVService = class extends BaseService {
|
|
|
1143
1458
|
return err(
|
|
1144
1459
|
serviceError(
|
|
1145
1460
|
ErrorCodes.KV_WRITE_FAILED,
|
|
1146
|
-
`Failed to put key
|
|
1461
|
+
`Failed to put key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
|
|
1147
1462
|
"kv",
|
|
1148
1463
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1149
1464
|
)
|
|
@@ -1155,6 +1470,8 @@ var KVService = class extends BaseService {
|
|
|
1155
1470
|
});
|
|
1156
1471
|
} catch (error) {
|
|
1157
1472
|
return err(wrapError("kv", error));
|
|
1473
|
+
} finally {
|
|
1474
|
+
request.dispose();
|
|
1158
1475
|
}
|
|
1159
1476
|
});
|
|
1160
1477
|
}
|
|
@@ -1194,6 +1511,7 @@ var KVService = class extends BaseService {
|
|
|
1194
1511
|
seen.add(path);
|
|
1195
1512
|
}
|
|
1196
1513
|
let requestMayHaveDispatched = false;
|
|
1514
|
+
const request = this.createRequestSignal(options?.signal, options?.timeout);
|
|
1197
1515
|
try {
|
|
1198
1516
|
const body = new FormData();
|
|
1199
1517
|
for (let index = 0; index < items.length; index++) {
|
|
@@ -1216,7 +1534,7 @@ var KVService = class extends BaseService {
|
|
|
1216
1534
|
method: "POST",
|
|
1217
1535
|
headers,
|
|
1218
1536
|
body,
|
|
1219
|
-
signal:
|
|
1537
|
+
signal: request.signal
|
|
1220
1538
|
};
|
|
1221
1539
|
const fetchFn = this.context.fetch;
|
|
1222
1540
|
requestMayHaveDispatched = true;
|
|
@@ -1237,12 +1555,13 @@ var KVService = class extends BaseService {
|
|
|
1237
1555
|
);
|
|
1238
1556
|
}
|
|
1239
1557
|
if (response.status === 401 || response.status === 403) {
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1558
|
+
return this.authorizationFailure(
|
|
1559
|
+
`Failed to batch put ${items.length} key(s)`,
|
|
1560
|
+
response,
|
|
1561
|
+
errorText,
|
|
1562
|
+
paths,
|
|
1563
|
+
KVAction.PUT
|
|
1564
|
+
);
|
|
1246
1565
|
}
|
|
1247
1566
|
const quotaError = this.handleQuotaErrorResponse(
|
|
1248
1567
|
response,
|
|
@@ -1266,14 +1585,16 @@ var KVService = class extends BaseService {
|
|
|
1266
1585
|
rawBody = await response.json();
|
|
1267
1586
|
} catch (jsonError) {
|
|
1268
1587
|
const cause = jsonError instanceof Error ? jsonError : new Error(String(jsonError));
|
|
1588
|
+
const timedOut = isRequestTimeoutError(cause);
|
|
1269
1589
|
return err(
|
|
1270
1590
|
serviceError(
|
|
1271
|
-
ErrorCodes.NETWORK_ERROR,
|
|
1272
|
-
`KV batchPut response was not valid JSON: ${cause.message}`,
|
|
1591
|
+
timedOut ? ErrorCodes.TIMEOUT : ErrorCodes.NETWORK_ERROR,
|
|
1592
|
+
timedOut ? `KV batchPut response body did not arrive in time: ${cause.message}` : `KV batchPut response was not valid JSON: ${cause.message}`,
|
|
1273
1593
|
"kv",
|
|
1274
1594
|
{
|
|
1275
1595
|
cause,
|
|
1276
1596
|
meta: {
|
|
1597
|
+
...timedOut ? { timeoutMs: cause.timeoutMs } : {},
|
|
1277
1598
|
requestMayHaveDispatched: true,
|
|
1278
1599
|
responseReceived: true,
|
|
1279
1600
|
status: response.status,
|
|
@@ -1328,6 +1649,8 @@ var KVService = class extends BaseService {
|
|
|
1328
1649
|
...wrapped,
|
|
1329
1650
|
meta: { ...wrapped.meta, requestMayHaveDispatched }
|
|
1330
1651
|
});
|
|
1652
|
+
} finally {
|
|
1653
|
+
request.dispose();
|
|
1331
1654
|
}
|
|
1332
1655
|
});
|
|
1333
1656
|
}
|
|
@@ -1350,26 +1673,27 @@ var KVService = class extends BaseService {
|
|
|
1350
1673
|
if (options?.path) {
|
|
1351
1674
|
listPath = listPath ? `${listPath}/${options.path}` : options.path;
|
|
1352
1675
|
}
|
|
1676
|
+
const request = this.createRequestSignal(options?.signal, options?.timeout);
|
|
1353
1677
|
try {
|
|
1354
1678
|
const response = await this.invokeOperation(
|
|
1355
1679
|
listPath,
|
|
1356
1680
|
KVAction.LIST,
|
|
1357
1681
|
void 0,
|
|
1358
|
-
|
|
1682
|
+
request.signal,
|
|
1359
1683
|
options?.limit === void 0 && options?.cursor === void 0 ? void 0 : {
|
|
1360
1684
|
...options?.limit === void 0 ? {} : { "x-tinycloud-limit": String(options.limit) },
|
|
1361
1685
|
...options?.cursor === void 0 ? {} : { "x-tinycloud-cursor": options.cursor }
|
|
1362
1686
|
}
|
|
1363
1687
|
);
|
|
1364
1688
|
if (!response.ok) {
|
|
1365
|
-
if (response.status === 401) {
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1689
|
+
if (response.status === 401 || response.status === 403) {
|
|
1690
|
+
return this.authorizationFailure(
|
|
1691
|
+
"Failed to list keys",
|
|
1692
|
+
response,
|
|
1693
|
+
await this.readAuthorizationText(response),
|
|
1694
|
+
[listPath],
|
|
1695
|
+
KVAction.LIST
|
|
1696
|
+
);
|
|
1373
1697
|
}
|
|
1374
1698
|
const errorText = await response.text();
|
|
1375
1699
|
return err(
|
|
@@ -1396,9 +1720,174 @@ var KVService = class extends BaseService {
|
|
|
1396
1720
|
});
|
|
1397
1721
|
} catch (error) {
|
|
1398
1722
|
return err(wrapError("kv", error));
|
|
1723
|
+
} finally {
|
|
1724
|
+
request.dispose();
|
|
1725
|
+
}
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* Read one page of the `tinycloud.kv/sync` change feed for a prefix.
|
|
1730
|
+
*/
|
|
1731
|
+
async changes(options) {
|
|
1732
|
+
return this.withTelemetry("changes", options?.prefix, async () => {
|
|
1733
|
+
if (!this.requireAuth()) {
|
|
1734
|
+
return err(authRequiredError("kv"));
|
|
1735
|
+
}
|
|
1736
|
+
if (typeof options?.prefix !== "string" || options.prefix.length === 0) {
|
|
1737
|
+
return err(serviceError(
|
|
1738
|
+
ErrorCodes.INVALID_INPUT,
|
|
1739
|
+
"KV changes prefix must be a non-empty string",
|
|
1740
|
+
"kv"
|
|
1741
|
+
));
|
|
1742
|
+
}
|
|
1743
|
+
if (options.limit !== void 0 && (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 1e3)) {
|
|
1744
|
+
return err(serviceError(
|
|
1745
|
+
ErrorCodes.INVALID_INPUT,
|
|
1746
|
+
"KV changes limit must be an integer from 1 through 1000",
|
|
1747
|
+
"kv"
|
|
1748
|
+
));
|
|
1749
|
+
}
|
|
1750
|
+
const headers = {};
|
|
1751
|
+
if (options.limit !== void 0) headers["x-tinycloud-limit"] = String(options.limit);
|
|
1752
|
+
if (options.cursor !== void 0) headers["x-tinycloud-cursor"] = options.cursor;
|
|
1753
|
+
if (options.retentionGrant !== void 0) {
|
|
1754
|
+
headers["x-tinycloud-retention-grant"] = options.retentionGrant;
|
|
1755
|
+
}
|
|
1756
|
+
const request = this.createRequestSignal(options.signal, options.timeout);
|
|
1757
|
+
try {
|
|
1758
|
+
const response = await this.invokeOperation(
|
|
1759
|
+
options.prefix,
|
|
1760
|
+
KVAction.SYNC,
|
|
1761
|
+
void 0,
|
|
1762
|
+
request.signal,
|
|
1763
|
+
headers
|
|
1764
|
+
);
|
|
1765
|
+
if (!response.ok) {
|
|
1766
|
+
return await this.changesFailure(response, options.prefix, request.signal);
|
|
1767
|
+
}
|
|
1768
|
+
const text = await response.text();
|
|
1769
|
+
let body;
|
|
1770
|
+
try {
|
|
1771
|
+
body = JSON.parse(text);
|
|
1772
|
+
} catch {
|
|
1773
|
+
body = void 0;
|
|
1774
|
+
}
|
|
1775
|
+
const page = this.normalizeChangesResponse(body);
|
|
1776
|
+
if (!page) {
|
|
1777
|
+
return err(serviceError(
|
|
1778
|
+
ErrorCodes.NETWORK_ERROR,
|
|
1779
|
+
"KV changes response was not a tinycloud.kv/sync page",
|
|
1780
|
+
"kv",
|
|
1781
|
+
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1782
|
+
));
|
|
1783
|
+
}
|
|
1784
|
+
return ok(page);
|
|
1785
|
+
} catch (error) {
|
|
1786
|
+
return err(wrapError("kv", error));
|
|
1787
|
+
} finally {
|
|
1788
|
+
request.dispose();
|
|
1399
1789
|
}
|
|
1400
1790
|
});
|
|
1401
1791
|
}
|
|
1792
|
+
/**
|
|
1793
|
+
* Map a failed `kv/sync` response. 410 and the retention 403 carry
|
|
1794
|
+
* `{"error":{"code","reason"}}`; a revoked grant is a 401 whose whole body
|
|
1795
|
+
* is the node's revocation error (see REVOKED_BODY). A body read that
|
|
1796
|
+
* fails because the request was cancelled or timed out rethrows, so the
|
|
1797
|
+
* caller sees `ABORTED`/`TIMEOUT` rather than a mapped status.
|
|
1798
|
+
*/
|
|
1799
|
+
async changesFailure(response, prefix, signal) {
|
|
1800
|
+
if (response.status === 404) {
|
|
1801
|
+
return this.classifyNotFound(response, prefix);
|
|
1802
|
+
}
|
|
1803
|
+
let errorText;
|
|
1804
|
+
try {
|
|
1805
|
+
errorText = await response.text();
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
if (signal.aborted) throw error;
|
|
1808
|
+
errorText = "";
|
|
1809
|
+
}
|
|
1810
|
+
const meta = { status: response.status, statusText: response.statusText };
|
|
1811
|
+
let reason;
|
|
1812
|
+
let bodyCode;
|
|
1813
|
+
try {
|
|
1814
|
+
const body = JSON.parse(errorText);
|
|
1815
|
+
const error = isJsonObject(body) ? body.error : void 0;
|
|
1816
|
+
if (isJsonObject(error)) {
|
|
1817
|
+
if (typeof error.code === "string") bodyCode = error.code;
|
|
1818
|
+
if (typeof error.reason === "string") reason = error.reason;
|
|
1819
|
+
}
|
|
1820
|
+
} catch {
|
|
1821
|
+
}
|
|
1822
|
+
const context = `Failed to read KV changes for ${JSON.stringify(prefix)}`;
|
|
1823
|
+
if (response.status === 410) {
|
|
1824
|
+
return err(serviceError(
|
|
1825
|
+
ErrorCodes.KV_SYNC_RESET_REQUIRED,
|
|
1826
|
+
`${context}: the node requires a reset (${reason ?? "reason not given"}); discard sync state and restart without a cursor`,
|
|
1827
|
+
"kv",
|
|
1828
|
+
{ meta: { ...meta, reason } }
|
|
1829
|
+
));
|
|
1830
|
+
}
|
|
1831
|
+
if (response.status === 403 && bodyCode === "RETENTION_GRANT_REFUSED") {
|
|
1832
|
+
return err(serviceError(
|
|
1833
|
+
ErrorCodes.KV_RETENTION_GRANT_REFUSED,
|
|
1834
|
+
`${context}: the retention grant was refused (${reason ?? "reason not given"})`,
|
|
1835
|
+
"kv",
|
|
1836
|
+
{ meta: { ...meta, reason } }
|
|
1837
|
+
));
|
|
1838
|
+
}
|
|
1839
|
+
if (response.status === 401) {
|
|
1840
|
+
const revoked = revocationCodeOf(errorText);
|
|
1841
|
+
if (revoked !== void 0) {
|
|
1842
|
+
return err(serviceError(revoked, `${context}: 401 - ${errorText}`, "kv", { meta }));
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
if (response.status === 401 || response.status === 403) {
|
|
1846
|
+
return this.authorizationFailure(context, response, errorText, [prefix], KVAction.SYNC);
|
|
1847
|
+
}
|
|
1848
|
+
return err(serviceError(
|
|
1849
|
+
response.status === 400 ? ErrorCodes.INVALID_INPUT : ErrorCodes.NETWORK_ERROR,
|
|
1850
|
+
`${context}: ${response.status} - ${errorText}`,
|
|
1851
|
+
"kv",
|
|
1852
|
+
{ meta }
|
|
1853
|
+
));
|
|
1854
|
+
}
|
|
1855
|
+
normalizeChangesResponse(data) {
|
|
1856
|
+
if (!isJsonObject(data)) return void 0;
|
|
1857
|
+
const { changes, more, cursor, source, authority } = data;
|
|
1858
|
+
if (!Array.isArray(changes) || typeof more !== "boolean" || typeof cursor !== "string" || !isJsonObject(source) || typeof source.nodeDid !== "string" || typeof source.space !== "string" || typeof source.prefix !== "string" || !isJsonObject(authority)) {
|
|
1859
|
+
return void 0;
|
|
1860
|
+
}
|
|
1861
|
+
const { notBefore, expiresAt, retainUntil } = authority;
|
|
1862
|
+
if (notBefore !== null && typeof notBefore !== "string" || expiresAt !== null && typeof expiresAt !== "string" || retainUntil !== null && typeof retainUntil !== "string") {
|
|
1863
|
+
return void 0;
|
|
1864
|
+
}
|
|
1865
|
+
const parsed = [];
|
|
1866
|
+
for (const change of changes) {
|
|
1867
|
+
if (!isJsonObject(change) || typeof change.key !== "string") return void 0;
|
|
1868
|
+
if (change.deleted === true) {
|
|
1869
|
+
parsed.push({ key: change.key, deleted: true });
|
|
1870
|
+
continue;
|
|
1871
|
+
}
|
|
1872
|
+
const { metadata } = change;
|
|
1873
|
+
if (change.deleted !== false || typeof change.etag !== "string" || !isJsonObject(metadata) || !Object.values(metadata).every((value) => typeof value === "string")) {
|
|
1874
|
+
return void 0;
|
|
1875
|
+
}
|
|
1876
|
+
parsed.push({
|
|
1877
|
+
key: change.key,
|
|
1878
|
+
deleted: false,
|
|
1879
|
+
etag: change.etag,
|
|
1880
|
+
metadata
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
return {
|
|
1884
|
+
changes: parsed,
|
|
1885
|
+
more,
|
|
1886
|
+
cursor,
|
|
1887
|
+
source: { nodeDid: source.nodeDid, space: source.space, prefix: source.prefix },
|
|
1888
|
+
authority: { notBefore, expiresAt, retainUntil }
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1402
1891
|
/**
|
|
1403
1892
|
* Delete a key.
|
|
1404
1893
|
*/
|
|
@@ -1408,23 +1897,24 @@ var KVService = class extends BaseService {
|
|
|
1408
1897
|
return err(authRequiredError("kv"));
|
|
1409
1898
|
}
|
|
1410
1899
|
const path = this.getFullPath(key, options?.prefix);
|
|
1900
|
+
const request = this.createRequestSignal(options?.signal, options?.timeout);
|
|
1411
1901
|
try {
|
|
1412
1902
|
const response = await this.invokeOperation(
|
|
1413
1903
|
path,
|
|
1414
1904
|
KVAction.DELETE,
|
|
1415
1905
|
void 0,
|
|
1416
|
-
|
|
1906
|
+
request.signal,
|
|
1417
1907
|
options?.ifMatch === void 0 ? void 0 : { "if-match": options.ifMatch }
|
|
1418
1908
|
);
|
|
1419
1909
|
if (!response.ok) {
|
|
1420
|
-
if (response.status === 401) {
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1910
|
+
if (response.status === 401 || response.status === 403) {
|
|
1911
|
+
return this.authorizationFailure(
|
|
1912
|
+
`Failed to delete key ${JSON.stringify(key)}`,
|
|
1913
|
+
response,
|
|
1914
|
+
await this.readAuthorizationText(response),
|
|
1915
|
+
[path],
|
|
1916
|
+
KVAction.DELETE
|
|
1917
|
+
);
|
|
1428
1918
|
}
|
|
1429
1919
|
if (response.status === 404) {
|
|
1430
1920
|
return this.classifyNotFound(response, key);
|
|
@@ -1433,7 +1923,7 @@ var KVService = class extends BaseService {
|
|
|
1433
1923
|
if (response.status === 412) {
|
|
1434
1924
|
return err(serviceError(
|
|
1435
1925
|
ErrorCodes.KV_PRECONDITION_FAILED,
|
|
1436
|
-
`KV precondition failed for key
|
|
1926
|
+
`KV precondition failed for key ${JSON.stringify(key)}`,
|
|
1437
1927
|
"kv",
|
|
1438
1928
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1439
1929
|
));
|
|
@@ -1441,7 +1931,7 @@ var KVService = class extends BaseService {
|
|
|
1441
1931
|
if (response.status === 503 && options?.ifMatch !== void 0) {
|
|
1442
1932
|
return err(serviceError(
|
|
1443
1933
|
ErrorCodes.KV_CONFLICT,
|
|
1444
|
-
`Concurrent KV delete conflicted for key
|
|
1934
|
+
`Concurrent KV delete conflicted for key ${JSON.stringify(key)}`,
|
|
1445
1935
|
"kv",
|
|
1446
1936
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1447
1937
|
));
|
|
@@ -1449,7 +1939,7 @@ var KVService = class extends BaseService {
|
|
|
1449
1939
|
return err(
|
|
1450
1940
|
serviceError(
|
|
1451
1941
|
ErrorCodes.NETWORK_ERROR,
|
|
1452
|
-
`Failed to delete key
|
|
1942
|
+
`Failed to delete key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
|
|
1453
1943
|
"kv",
|
|
1454
1944
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1455
1945
|
)
|
|
@@ -1461,6 +1951,8 @@ var KVService = class extends BaseService {
|
|
|
1461
1951
|
});
|
|
1462
1952
|
} catch (error) {
|
|
1463
1953
|
return err(wrapError("kv", error));
|
|
1954
|
+
} finally {
|
|
1955
|
+
request.dispose();
|
|
1464
1956
|
}
|
|
1465
1957
|
});
|
|
1466
1958
|
}
|
|
@@ -1473,22 +1965,23 @@ var KVService = class extends BaseService {
|
|
|
1473
1965
|
return err(authRequiredError("kv"));
|
|
1474
1966
|
}
|
|
1475
1967
|
const path = this.getFullPath(key, options?.prefix);
|
|
1968
|
+
const request = this.createRequestSignal(options?.signal, options?.timeout);
|
|
1476
1969
|
try {
|
|
1477
1970
|
const response = await this.invokeOperation(
|
|
1478
1971
|
path,
|
|
1479
1972
|
KVAction.HEAD,
|
|
1480
1973
|
void 0,
|
|
1481
|
-
|
|
1974
|
+
request.signal
|
|
1482
1975
|
);
|
|
1483
1976
|
if (!response.ok) {
|
|
1484
|
-
if (response.status === 401) {
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1977
|
+
if (response.status === 401 || response.status === 403) {
|
|
1978
|
+
return this.authorizationFailure(
|
|
1979
|
+
`Failed to get metadata for key ${JSON.stringify(key)}`,
|
|
1980
|
+
response,
|
|
1981
|
+
await this.readAuthorizationText(response),
|
|
1982
|
+
[path],
|
|
1983
|
+
KVAction.HEAD
|
|
1984
|
+
);
|
|
1492
1985
|
}
|
|
1493
1986
|
if (response.status === 404) {
|
|
1494
1987
|
return this.classifyNotFound(response, key);
|
|
@@ -1497,7 +1990,7 @@ var KVService = class extends BaseService {
|
|
|
1497
1990
|
return err(
|
|
1498
1991
|
serviceError(
|
|
1499
1992
|
ErrorCodes.NETWORK_ERROR,
|
|
1500
|
-
`Failed to get metadata for key
|
|
1993
|
+
`Failed to get metadata for key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
|
|
1501
1994
|
"kv",
|
|
1502
1995
|
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1503
1996
|
)
|
|
@@ -1509,6 +2002,8 @@ var KVService = class extends BaseService {
|
|
|
1509
2002
|
});
|
|
1510
2003
|
} catch (error) {
|
|
1511
2004
|
return err(wrapError("kv", error));
|
|
2005
|
+
} finally {
|
|
2006
|
+
request.dispose();
|
|
1512
2007
|
}
|
|
1513
2008
|
});
|
|
1514
2009
|
}
|
|
@@ -1546,15 +2041,16 @@ var KVService = class extends BaseService {
|
|
|
1546
2041
|
if (options?.etag !== void 0) {
|
|
1547
2042
|
body.etag = options.etag;
|
|
1548
2043
|
}
|
|
2044
|
+
const request = this.createRequestSignal(options?.signal, options?.timeout);
|
|
1549
2045
|
try {
|
|
1550
2046
|
const response = await this.context.fetch(`${this.host}/signed/kv`, {
|
|
1551
2047
|
method: "POST",
|
|
1552
2048
|
headers: this.withJsonContentType(headers),
|
|
1553
2049
|
body: JSON.stringify(body),
|
|
1554
|
-
signal:
|
|
2050
|
+
signal: request.signal
|
|
1555
2051
|
});
|
|
1556
2052
|
if (!response.ok) {
|
|
1557
|
-
return this.createSignedReadUrlError(response, key);
|
|
2053
|
+
return this.createSignedReadUrlError(response, key, path);
|
|
1558
2054
|
}
|
|
1559
2055
|
const signedUrl = this.normalizeSignedReadUrlResponse(
|
|
1560
2056
|
await response.json()
|
|
@@ -1571,6 +2067,8 @@ var KVService = class extends BaseService {
|
|
|
1571
2067
|
return ok(signedUrl);
|
|
1572
2068
|
} catch (error) {
|
|
1573
2069
|
return err(wrapError("kv", error));
|
|
2070
|
+
} finally {
|
|
2071
|
+
request.dispose();
|
|
1574
2072
|
}
|
|
1575
2073
|
});
|
|
1576
2074
|
}
|