@tinycloud/sdk-services 3.1.0-beta.7 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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",
@@ -705,6 +713,30 @@ var PrefixedKVService = class _PrefixedKVService {
705
713
  removePrefix
706
714
  });
707
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
+ }
708
740
  /**
709
741
  * Delete a key.
710
742
  */
@@ -757,11 +789,36 @@ var KVAction = {
757
789
  PUT: "tinycloud.kv/put",
758
790
  LIST: "tinycloud.kv/list",
759
791
  DELETE: "tinycloud.kv/del",
760
- 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"
761
797
  };
762
798
 
763
799
  // src/kv/KVService.ts
764
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 pathByteEncoder = new TextEncoder();
805
+ function comparePathsByBytes(a, b) {
806
+ const left = pathByteEncoder.encode(a);
807
+ const right = pathByteEncoder.encode(b);
808
+ const length = Math.min(left.length, right.length);
809
+ for (let index = 0; index < length; index++) {
810
+ if (left[index] !== right[index]) {
811
+ return left[index] - right[index];
812
+ }
813
+ }
814
+ return left.length - right.length;
815
+ }
816
+ var REVOKED_BODY = /^(?:Invalid invocation: )?delegation-(?:(revoked): [A-Za-z0-9]+|(ancestor-revoked): ancestor=[A-Za-z0-9]+ invoked=[A-Za-z0-9]+)$/;
817
+ function revocationCodeOf(body) {
818
+ const match = REVOKED_BODY.exec(body.trim());
819
+ if (match === null) return void 0;
820
+ return match[1] !== void 0 ? ErrorCodes.AUTH_DELEGATION_REVOKED : ErrorCodes.AUTH_DELEGATION_ANCESTOR_REVOKED;
821
+ }
765
822
  function encodeKvBatchPartName(path) {
766
823
  return encodeURIComponent(path).replace(
767
824
  /[!'()*]/g,
@@ -989,15 +1046,25 @@ var KVService = class extends BaseService {
989
1046
  )?.[1];
990
1047
  return contentType?.includes("application/json") ? JSON.parse(text) : text;
991
1048
  }
1049
+ /**
1050
+ * Validate a batch read response and return its items in the caller's
1051
+ * request order. The node flattens invocation abilities from a `BTreeMap`,
1052
+ * so results arrive sorted by UTF-8 byte order of the path, not in request
1053
+ * order. Items that carry their path in `key` are matched by path; if no
1054
+ * item carries a key, items are assumed to be in the node's byte-sorted
1055
+ * order and the request is aligned the same way. Any mix of keyed and
1056
+ * keyless items, a missing requested path, an unrequested path, or a
1057
+ * malformed item fails closed (returns undefined).
1058
+ */
992
1059
  normalizeBatchReadResponse(data, paths, requireData) {
993
1060
  if (!data || typeof data !== "object") return void 0;
994
1061
  const response = data;
995
- if (!Array.isArray(response.results) || response.results.length !== paths.length) {
1062
+ const items = response.results;
1063
+ if (!Array.isArray(items) || items.length !== paths.length) {
996
1064
  return void 0;
997
1065
  }
998
- for (let index = 0; index < response.results.length; index++) {
999
- const item = response.results[index];
1000
- if (!item || typeof item !== "object" || item.key !== paths[index] || typeof item.ok !== "boolean") {
1066
+ for (const item of items) {
1067
+ if (!item || typeof item !== "object" || typeof item.ok !== "boolean") {
1001
1068
  return void 0;
1002
1069
  }
1003
1070
  if (item.ok && (!item.headers || typeof item.headers !== "object" || Object.values(item.headers).some((value) => typeof value !== "string") || requireData && typeof item.dataBase64 !== "string")) {
@@ -1007,7 +1074,24 @@ var KVService = class extends BaseService {
1007
1074
  return void 0;
1008
1075
  }
1009
1076
  }
1010
- return response;
1077
+ if (items.every((item) => typeof item.key === "string")) {
1078
+ const byPath = new Map(items.map((item) => [item.key, item]));
1079
+ if (byPath.size !== items.length) return void 0;
1080
+ const aligned2 = [];
1081
+ for (const path of paths) {
1082
+ const item = byPath.get(path);
1083
+ if (!item) return void 0;
1084
+ aligned2.push(item);
1085
+ }
1086
+ return aligned2;
1087
+ }
1088
+ if (items.some((item) => item.key !== void 0)) return void 0;
1089
+ const requestOrder = paths.map((_, index) => index).sort((a, b) => comparePathsByBytes(paths[a], paths[b]));
1090
+ const aligned = new Array(items.length);
1091
+ for (let position = 0; position < requestOrder.length; position++) {
1092
+ aligned[requestOrder[position]] = items[position];
1093
+ }
1094
+ return aligned;
1011
1095
  }
1012
1096
  async batchRead(keys, action, options) {
1013
1097
  if (!this.requireAuth()) return err(authRequiredError("kv"));
@@ -1100,19 +1184,19 @@ var KVService = class extends BaseService {
1100
1184
  { meta: { status: response.status, statusText: response.statusText } }
1101
1185
  ));
1102
1186
  }
1103
- const payload = this.normalizeBatchReadResponse(
1187
+ const items = this.normalizeBatchReadResponse(
1104
1188
  await response.json(),
1105
1189
  paths,
1106
1190
  action === KVAction.GET
1107
1191
  );
1108
- if (!payload) {
1192
+ if (!items) {
1109
1193
  return err(serviceError(
1110
1194
  ErrorCodes.NETWORK_ERROR,
1111
1195
  "KV batch read response did not match the requested keys",
1112
1196
  "kv"
1113
1197
  ));
1114
1198
  }
1115
- const results = payload.results.map((item, index) => {
1199
+ const results = items.map((item, index) => {
1116
1200
  if (!item.ok) {
1117
1201
  return {
1118
1202
  key: keys[index],
@@ -1680,6 +1764,169 @@ var KVService = class extends BaseService {
1680
1764
  }
1681
1765
  });
1682
1766
  }
1767
+ /**
1768
+ * Read one page of the `tinycloud.kv/sync` change feed for a prefix.
1769
+ */
1770
+ async changes(options) {
1771
+ return this.withTelemetry("changes", options?.prefix, async () => {
1772
+ if (!this.requireAuth()) {
1773
+ return err(authRequiredError("kv"));
1774
+ }
1775
+ if (typeof options?.prefix !== "string" || options.prefix.length === 0) {
1776
+ return err(serviceError(
1777
+ ErrorCodes.INVALID_INPUT,
1778
+ "KV changes prefix must be a non-empty string",
1779
+ "kv"
1780
+ ));
1781
+ }
1782
+ if (options.limit !== void 0 && (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 1e3)) {
1783
+ return err(serviceError(
1784
+ ErrorCodes.INVALID_INPUT,
1785
+ "KV changes limit must be an integer from 1 through 1000",
1786
+ "kv"
1787
+ ));
1788
+ }
1789
+ const headers = {};
1790
+ if (options.limit !== void 0) headers["x-tinycloud-limit"] = String(options.limit);
1791
+ if (options.cursor !== void 0) headers["x-tinycloud-cursor"] = options.cursor;
1792
+ if (options.retentionGrant !== void 0) {
1793
+ headers["x-tinycloud-retention-grant"] = options.retentionGrant;
1794
+ }
1795
+ const request = this.createRequestSignal(options.signal, options.timeout);
1796
+ try {
1797
+ const response = await this.invokeOperation(
1798
+ options.prefix,
1799
+ KVAction.SYNC,
1800
+ void 0,
1801
+ request.signal,
1802
+ headers
1803
+ );
1804
+ if (!response.ok) {
1805
+ return await this.changesFailure(response, options.prefix, request.signal);
1806
+ }
1807
+ const text = await response.text();
1808
+ let body;
1809
+ try {
1810
+ body = JSON.parse(text);
1811
+ } catch {
1812
+ body = void 0;
1813
+ }
1814
+ const page = this.normalizeChangesResponse(body);
1815
+ if (!page) {
1816
+ return err(serviceError(
1817
+ ErrorCodes.NETWORK_ERROR,
1818
+ "KV changes response was not a tinycloud.kv/sync page",
1819
+ "kv",
1820
+ { meta: { status: response.status, statusText: response.statusText } }
1821
+ ));
1822
+ }
1823
+ return ok(page);
1824
+ } catch (error) {
1825
+ return err(wrapError("kv", error));
1826
+ } finally {
1827
+ request.dispose();
1828
+ }
1829
+ });
1830
+ }
1831
+ /**
1832
+ * Map a failed `kv/sync` response. 410 and the retention 403 carry
1833
+ * `{"error":{"code","reason"}}`; a revoked grant is a 401 whose whole body
1834
+ * is the node's revocation error (see REVOKED_BODY). A body read that
1835
+ * fails because the request was cancelled or timed out rethrows, so the
1836
+ * caller sees `ABORTED`/`TIMEOUT` rather than a mapped status.
1837
+ */
1838
+ async changesFailure(response, prefix, signal) {
1839
+ if (response.status === 404) {
1840
+ return this.classifyNotFound(response, prefix);
1841
+ }
1842
+ let errorText;
1843
+ try {
1844
+ errorText = await response.text();
1845
+ } catch (error) {
1846
+ if (signal.aborted) throw error;
1847
+ errorText = "";
1848
+ }
1849
+ const meta = { status: response.status, statusText: response.statusText };
1850
+ let reason;
1851
+ let bodyCode;
1852
+ try {
1853
+ const body = JSON.parse(errorText);
1854
+ const error = isJsonObject(body) ? body.error : void 0;
1855
+ if (isJsonObject(error)) {
1856
+ if (typeof error.code === "string") bodyCode = error.code;
1857
+ if (typeof error.reason === "string") reason = error.reason;
1858
+ }
1859
+ } catch {
1860
+ }
1861
+ const context = `Failed to read KV changes for ${JSON.stringify(prefix)}`;
1862
+ if (response.status === 410) {
1863
+ return err(serviceError(
1864
+ ErrorCodes.KV_SYNC_RESET_REQUIRED,
1865
+ `${context}: the node requires a reset (${reason ?? "reason not given"}); discard sync state and restart without a cursor`,
1866
+ "kv",
1867
+ { meta: { ...meta, reason } }
1868
+ ));
1869
+ }
1870
+ if (response.status === 403 && bodyCode === "RETENTION_GRANT_REFUSED") {
1871
+ return err(serviceError(
1872
+ ErrorCodes.KV_RETENTION_GRANT_REFUSED,
1873
+ `${context}: the retention grant was refused (${reason ?? "reason not given"})`,
1874
+ "kv",
1875
+ { meta: { ...meta, reason } }
1876
+ ));
1877
+ }
1878
+ if (response.status === 401) {
1879
+ const revoked = revocationCodeOf(errorText);
1880
+ if (revoked !== void 0) {
1881
+ return err(serviceError(revoked, `${context}: 401 - ${errorText}`, "kv", { meta }));
1882
+ }
1883
+ }
1884
+ if (response.status === 401 || response.status === 403) {
1885
+ return this.authorizationFailure(context, response, errorText, [prefix], KVAction.SYNC);
1886
+ }
1887
+ return err(serviceError(
1888
+ response.status === 400 ? ErrorCodes.INVALID_INPUT : ErrorCodes.NETWORK_ERROR,
1889
+ `${context}: ${response.status} - ${errorText}`,
1890
+ "kv",
1891
+ { meta }
1892
+ ));
1893
+ }
1894
+ normalizeChangesResponse(data) {
1895
+ if (!isJsonObject(data)) return void 0;
1896
+ const { changes, more, cursor, source, authority } = data;
1897
+ 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)) {
1898
+ return void 0;
1899
+ }
1900
+ const { notBefore, expiresAt, retainUntil } = authority;
1901
+ if (notBefore !== null && typeof notBefore !== "string" || expiresAt !== null && typeof expiresAt !== "string" || retainUntil !== null && typeof retainUntil !== "string") {
1902
+ return void 0;
1903
+ }
1904
+ const parsed = [];
1905
+ for (const change of changes) {
1906
+ if (!isJsonObject(change) || typeof change.key !== "string") return void 0;
1907
+ if (change.deleted === true) {
1908
+ parsed.push({ key: change.key, deleted: true });
1909
+ continue;
1910
+ }
1911
+ const { metadata } = change;
1912
+ if (change.deleted !== false || typeof change.etag !== "string" || !isJsonObject(metadata) || !Object.values(metadata).every((value) => typeof value === "string")) {
1913
+ return void 0;
1914
+ }
1915
+ parsed.push({
1916
+ key: change.key,
1917
+ deleted: false,
1918
+ etag: change.etag,
1919
+ metadata
1920
+ });
1921
+ }
1922
+ return {
1923
+ changes: parsed,
1924
+ more,
1925
+ cursor,
1926
+ source: { nodeDid: source.nodeDid, space: source.space, prefix: source.prefix },
1927
+ authority: { notBefore, expiresAt, retainUntil }
1928
+ };
1929
+ }
1683
1930
  /**
1684
1931
  * Delete a key.
1685
1932
  */