@tinycloud/sdk-services 3.1.0 → 3.2.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/{BaseService-DWbPbov7.d.cts → BaseService-BDQXjWMx.d.cts} +1 -1
  2. package/dist/{BaseService-B_UKhVht.d.ts → BaseService-Di_hVfUV.d.ts} +1 -1
  3. package/dist/encryption/index.cjs +7 -1
  4. package/dist/encryption/index.cjs.map +1 -1
  5. package/dist/encryption/index.d.cts +2 -2
  6. package/dist/encryption/index.d.ts +2 -2
  7. package/dist/encryption/index.js +7 -1
  8. package/dist/encryption/index.js.map +1 -1
  9. package/dist/index.cjs +683 -362
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +81 -29
  12. package/dist/index.d.ts +81 -29
  13. package/dist/index.js +674 -362
  14. package/dist/index.js.map +1 -1
  15. package/dist/internal/decrypt-transport-response-error.d.cts +1 -1
  16. package/dist/internal/decrypt-transport-response-error.d.ts +1 -1
  17. package/dist/kv/index.cjs +532 -336
  18. package/dist/kv/index.cjs.map +1 -1
  19. package/dist/kv/index.d.cts +17 -468
  20. package/dist/kv/index.d.ts +17 -468
  21. package/dist/kv/index.js +526 -335
  22. package/dist/kv/index.js.map +1 -1
  23. package/dist/kv/replication/index.cjs +1375 -0
  24. package/dist/kv/replication/index.cjs.map +1 -0
  25. package/dist/kv/replication/index.d.cts +9 -0
  26. package/dist/kv/replication/index.d.ts +9 -0
  27. package/dist/kv/replication/index.js +1342 -0
  28. package/dist/kv/replication/index.js.map +1 -0
  29. package/dist/scope-BmLxwyl-.d.ts +957 -0
  30. package/dist/scope-D855kRDx.d.cts +957 -0
  31. package/dist/sql/index.cjs +12 -6
  32. package/dist/sql/index.cjs.map +1 -1
  33. package/dist/sql/index.d.cts +2 -2
  34. package/dist/sql/index.d.ts +2 -2
  35. package/dist/sql/index.js +12 -6
  36. package/dist/sql/index.js.map +1 -1
  37. package/dist/{types-CVwwxqXF.d.cts → types-7A5rHWRY.d.cts} +10 -1
  38. package/dist/{types-CVwwxqXF.d.ts → types-7A5rHWRY.d.ts} +10 -1
  39. package/package.json +18 -1
package/dist/kv/index.cjs CHANGED
@@ -20,10 +20,15 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/kv/index.ts
21
21
  var kv_exports = {};
22
22
  __export(kv_exports, {
23
+ CLEAR_PENDING_WARNING: () => CLEAR_PENDING_WARNING,
23
24
  DEFAULT_SIGNED_READ_URL_EXPIRY_MS: () => DEFAULT_SIGNED_READ_URL_EXPIRY_MS,
24
25
  KVAction: () => KVAction,
25
26
  KVService: () => KVService,
26
- PrefixedKVService: () => PrefixedKVService
27
+ PrefixedKVService: () => PrefixedKVService,
28
+ canonicalReplicationIdentity: () => canonicalReplicationIdentity,
29
+ kvPrefixCovers: () => kvPrefixCovers,
30
+ replicationIdentityKey: () => replicationIdentityKey,
31
+ requiresSecretsOptIn: () => requiresSecretsOptIn
27
32
  });
28
33
  module.exports = __toCommonJS(kv_exports);
29
34
 
@@ -89,7 +94,12 @@ var TelemetryEvents = {
89
94
  SERVICE_ERROR: "service.error",
90
95
  SERVICE_RETRY: "service.retry",
91
96
  SESSION_CHANGED: "session.changed",
92
- SESSION_EXPIRED: "session.expired"
97
+ SESSION_EXPIRED: "session.expired",
98
+ REPLICATION_READ: "replication.read",
99
+ REPLICATION_WRITE: "replication.write",
100
+ REPLICATION_SYNC: "replication.sync",
101
+ REPLICATION_STATE: "replication.state",
102
+ REPLICATION_DIVERGENCE: "replication.divergence"
93
103
  };
94
104
  function ok(data) {
95
105
  return { ok: true, data };
@@ -617,6 +627,7 @@ var BaseService = class {
617
627
  const parents = [
618
628
  this.abortController.signal,
619
629
  this.context?.abortSignal,
630
+ this.context?.operationAbortSignal,
620
631
  signal
621
632
  ].filter((parent) => parent !== void 0);
622
633
  for (const parent of parents) {
@@ -862,14 +873,28 @@ var KVService = class extends BaseService {
862
873
  */
863
874
  constructor(config = {}) {
864
875
  super();
876
+ this.readThrough = null;
877
+ this.readThroughReady = null;
865
878
  this._config = config;
866
879
  }
880
+ /** Delay KV routing until an asynchronously loaded read-through is attached. */
881
+ setReadThroughReady(ready) {
882
+ const settled = ready.then(() => void 0, () => void 0);
883
+ this.readThroughReady = settled;
884
+ void settled.then(() => {
885
+ if (this.readThroughReady === settled) this.readThroughReady = null;
886
+ });
887
+ }
867
888
  /**
868
889
  * Get the service configuration.
869
890
  */
870
891
  get config() {
871
892
  return this._config;
872
893
  }
894
+ /** Install or remove the optional replication read-through policy. */
895
+ setReadThrough(readThrough) {
896
+ this.readThrough = readThrough;
897
+ }
873
898
  handleQuotaErrorResponse(response, errorText, key) {
874
899
  if (response.status !== 402 && response.status !== 413) {
875
900
  return void 0;
@@ -1123,6 +1148,7 @@ var KVService = class extends BaseService {
1123
1148
  return aligned;
1124
1149
  }
1125
1150
  async batchRead(keys, action, options) {
1151
+ if (this.readThroughReady !== null) await this.readThroughReady;
1126
1152
  if (!this.requireAuth()) return err(authRequiredError("kv"));
1127
1153
  if (keys.length === 0) return ok({ results: [], count: 0 });
1128
1154
  if (keys.length > MAX_KV_BATCH_READ_ITEMS) {
@@ -1373,6 +1399,7 @@ var KVService = class extends BaseService {
1373
1399
  */
1374
1400
  async get(key, options) {
1375
1401
  return this.withTelemetry("get", key, async () => {
1402
+ if (this.readThroughReady !== null) await this.readThroughReady;
1376
1403
  if (!this.requireAuth()) {
1377
1404
  return err(authRequiredError("kv"));
1378
1405
  }
@@ -1386,64 +1413,38 @@ var KVService = class extends BaseService {
1386
1413
  const path = this.getFullPath(key, options?.prefix);
1387
1414
  const request = this.createRequestSignal(options?.signal, options?.timeout);
1388
1415
  try {
1389
- const response = await this.invokeOperation(
1390
- path,
1391
- KVAction.GET,
1392
- void 0,
1393
- request.signal,
1394
- options?.maxResponseBytes === void 0 ? void 0 : { "x-tinycloud-max-response-bytes": String(options.maxResponseBytes) }
1395
- );
1396
- if (!response.ok) {
1397
- if (response.status === 401 || response.status === 403) {
1398
- const errorText2 = await this.readAuthorizationText(response);
1399
- const permissionHint = parsePermissionHintFromErrorText(errorText2);
1400
- const structuredCapability = permissionHint?.service === "tinycloud.kv" && permissionHint.space === this.context.session.spaceId && permissionHint.path === path ? validatedCapabilityOf({
1401
- service: "kv",
1402
- code: ErrorCodes.AUTH_UNAUTHORIZED,
1403
- meta: {
1404
- status: response.status,
1405
- resource: `${this.context.session.spaceId}/kv/${path}`,
1406
- requiredAction: KVAction.GET
1407
- }
1408
- }) : void 0;
1409
- return this.authorizationFailure(
1410
- `Failed to get key ${JSON.stringify(key)}`,
1411
- response,
1412
- errorText2,
1413
- [path],
1414
- KVAction.GET,
1415
- structuredCapability ? { permissionHint, ...structuredCapability } : {}
1416
- );
1417
- }
1418
- if (response.status === 404) {
1419
- return this.classifyNotFound(response, key);
1420
- }
1421
- const errorText = await response.text();
1422
- if (response.status === 413) {
1423
- return err(serviceError(
1424
- ErrorCodes.KV_RESPONSE_TOO_LARGE,
1425
- `KV value at key ${JSON.stringify(key)} exceeds the requested response limit`,
1426
- "kv",
1427
- { meta: { status: response.status, statusText: response.statusText } }
1428
- ));
1416
+ const network = () => this.getFromNetwork(key, path, options, request.signal);
1417
+ if (options?.source === "network") {
1418
+ const observer = this.readThrough;
1419
+ if (observer === null) return await network();
1420
+ let outcome = "error";
1421
+ const startedAt = Date.now();
1422
+ try {
1423
+ const result = await network();
1424
+ outcome = result.ok ? "found" : result.error.code === ErrorCodes.KV_NOT_FOUND ? "not_found" : "error";
1425
+ return result;
1426
+ } finally {
1427
+ try {
1428
+ observer.observeNetworkRequested({
1429
+ op: "get",
1430
+ space: this.context.session.spaceId,
1431
+ path,
1432
+ reason: "NETWORK_REQUESTED",
1433
+ outcome,
1434
+ latencyMs: Date.now() - startedAt
1435
+ });
1436
+ } catch {
1437
+ }
1429
1438
  }
1430
- return err(
1431
- serviceError(
1432
- ErrorCodes.NETWORK_ERROR,
1433
- `Failed to get key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
1434
- "kv",
1435
- { meta: { status: response.status, statusText: response.statusText } }
1436
- )
1437
- );
1438
1439
  }
1439
- const data = await this.parseResponse(
1440
- response,
1441
- options?.raw,
1442
- options?.binary
1443
- );
1444
- return ok({
1445
- data,
1446
- headers: this.createResponseHeaders(response.headers)
1440
+ if (this.readThrough === null) return await network();
1441
+ return await this.readThrough.get({
1442
+ space: this.context.session.spaceId,
1443
+ key,
1444
+ path,
1445
+ options,
1446
+ signal: request.signal,
1447
+ network
1447
1448
  });
1448
1449
  } catch (error) {
1449
1450
  return err(wrapError("kv", error));
@@ -1452,6 +1453,56 @@ var KVService = class extends BaseService {
1452
1453
  }
1453
1454
  });
1454
1455
  }
1456
+ async getFromNetwork(key, path, options, signal) {
1457
+ const response = await this.invokeOperation(
1458
+ path,
1459
+ KVAction.GET,
1460
+ void 0,
1461
+ signal,
1462
+ options?.maxResponseBytes === void 0 ? void 0 : { "x-tinycloud-max-response-bytes": String(options.maxResponseBytes) }
1463
+ );
1464
+ if (!response.ok) {
1465
+ if (response.status === 401 || response.status === 403) {
1466
+ const errorText2 = await this.readAuthorizationText(response);
1467
+ const permissionHint = parsePermissionHintFromErrorText(errorText2);
1468
+ const structuredCapability = permissionHint?.service === "tinycloud.kv" && permissionHint.space === this.context.session.spaceId && permissionHint.path === path ? validatedCapabilityOf({
1469
+ service: "kv",
1470
+ code: ErrorCodes.AUTH_UNAUTHORIZED,
1471
+ meta: {
1472
+ status: response.status,
1473
+ resource: `${this.context.session.spaceId}/kv/${path}`,
1474
+ requiredAction: KVAction.GET
1475
+ }
1476
+ }) : void 0;
1477
+ return this.authorizationFailure(
1478
+ `Failed to get key ${JSON.stringify(key)}`,
1479
+ response,
1480
+ errorText2,
1481
+ [path],
1482
+ KVAction.GET,
1483
+ structuredCapability ? { permissionHint, ...structuredCapability } : {}
1484
+ );
1485
+ }
1486
+ if (response.status === 404) return this.classifyNotFound(response, key);
1487
+ const errorText = await response.text();
1488
+ if (response.status === 413) {
1489
+ return err(serviceError(
1490
+ ErrorCodes.KV_RESPONSE_TOO_LARGE,
1491
+ `KV value at key ${JSON.stringify(key)} exceeds the requested response limit`,
1492
+ "kv",
1493
+ { meta: { status: response.status, statusText: response.statusText } }
1494
+ ));
1495
+ }
1496
+ return err(serviceError(
1497
+ ErrorCodes.NETWORK_ERROR,
1498
+ `Failed to get key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
1499
+ "kv",
1500
+ { meta: { status: response.status, statusText: response.statusText } }
1501
+ ));
1502
+ }
1503
+ const data = await this.parseResponse(response, options?.raw, options?.binary);
1504
+ return ok({ data, headers: this.createResponseHeaders(response.headers) });
1505
+ }
1455
1506
  async batchGet(keys, options) {
1456
1507
  return this.withTelemetry(
1457
1508
  "batchGet",
@@ -1464,6 +1515,7 @@ var KVService = class extends BaseService {
1464
1515
  */
1465
1516
  async put(key, value, options) {
1466
1517
  return this.withTelemetry("put", key, async () => {
1518
+ if (this.readThroughReady !== null) await this.readThroughReady;
1467
1519
  if (!this.requireAuth()) {
1468
1520
  return err(authRequiredError("kv"));
1469
1521
  }
@@ -1478,63 +1530,15 @@ var KVService = class extends BaseService {
1478
1530
  const body = this.serializePutValue(value, options?.contentType);
1479
1531
  const request = this.createRequestSignal(options?.signal, options?.timeout);
1480
1532
  try {
1481
- const response = await this.invokeOperation(
1482
- path,
1483
- KVAction.PUT,
1484
- body,
1485
- request.signal,
1486
- {
1487
- ...options?.ifMatch === void 0 ? {} : { "if-match": options.ifMatch },
1488
- ...options?.ifNoneMatch === void 0 ? {} : { "if-none-match": options.ifNoneMatch }
1489
- }
1490
- );
1491
- if (response.status === 401 || response.status === 403) {
1492
- return this.authorizationFailure(
1493
- `Failed to put key ${JSON.stringify(key)}`,
1494
- response,
1495
- await this.readAuthorizationText(response),
1496
- [path],
1497
- KVAction.PUT
1498
- );
1499
- }
1500
- if (!response.ok) {
1501
- const errorText = await response.text();
1502
- if (response.status === 412) {
1503
- return err(serviceError(
1504
- ErrorCodes.KV_PRECONDITION_FAILED,
1505
- `KV precondition failed for key ${JSON.stringify(key)}`,
1506
- "kv",
1507
- { meta: { status: response.status, statusText: response.statusText } }
1508
- ));
1509
- }
1510
- if (response.status === 503 && (options?.ifMatch !== void 0 || options?.ifNoneMatch !== void 0)) {
1511
- return err(serviceError(
1512
- ErrorCodes.KV_CONFLICT,
1513
- `Concurrent KV update conflicted for key ${JSON.stringify(key)}`,
1514
- "kv",
1515
- { meta: { status: response.status, statusText: response.statusText } }
1516
- ));
1517
- }
1518
- const quotaError = this.handleQuotaErrorResponse(
1519
- response,
1520
- errorText,
1521
- key
1522
- );
1523
- if (quotaError) {
1524
- return quotaError;
1525
- }
1526
- return err(
1527
- serviceError(
1528
- ErrorCodes.KV_WRITE_FAILED,
1529
- `Failed to put key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
1530
- "kv",
1531
- { meta: { status: response.status, statusText: response.statusText } }
1532
- )
1533
- );
1534
- }
1535
- return ok({
1536
- data: void 0,
1537
- headers: this.createResponseHeaders(response.headers)
1533
+ const network = () => this.putToNetwork(key, path, body, options, request.signal);
1534
+ if (this.readThrough === null) return await network();
1535
+ return await this.readThrough.write({
1536
+ op: "put",
1537
+ space: this.context.session.spaceId,
1538
+ entries: [{ path, body }],
1539
+ signal: request.signal,
1540
+ conditional: options?.ifMatch !== void 0 || options?.ifNoneMatch !== void 0,
1541
+ network
1538
1542
  });
1539
1543
  } catch (error) {
1540
1544
  return err(wrapError("kv", error));
@@ -1543,11 +1547,64 @@ var KVService = class extends BaseService {
1543
1547
  }
1544
1548
  });
1545
1549
  }
1550
+ async putToNetwork(key, path, body, options, signal) {
1551
+ const response = await this.invokeOperation(
1552
+ path,
1553
+ KVAction.PUT,
1554
+ body,
1555
+ signal,
1556
+ {
1557
+ ...options?.ifMatch === void 0 ? {} : { "if-match": options.ifMatch },
1558
+ ...options?.ifNoneMatch === void 0 ? {} : { "if-none-match": options.ifNoneMatch }
1559
+ }
1560
+ );
1561
+ if (response.status === 401 || response.status === 403) {
1562
+ return this.authorizationFailure(
1563
+ `Failed to put key ${JSON.stringify(key)}`,
1564
+ response,
1565
+ await this.readAuthorizationText(response),
1566
+ [path],
1567
+ KVAction.PUT
1568
+ );
1569
+ }
1570
+ if (!response.ok) {
1571
+ const errorText = await response.text();
1572
+ if (response.status === 412) {
1573
+ return err(serviceError(
1574
+ ErrorCodes.KV_PRECONDITION_FAILED,
1575
+ `KV precondition failed for key ${JSON.stringify(key)}`,
1576
+ "kv",
1577
+ { meta: { status: response.status, statusText: response.statusText } }
1578
+ ));
1579
+ }
1580
+ if (response.status === 503 && (options?.ifMatch !== void 0 || options?.ifNoneMatch !== void 0)) {
1581
+ return err(serviceError(
1582
+ ErrorCodes.KV_CONFLICT,
1583
+ `Concurrent KV update conflicted for key ${JSON.stringify(key)}`,
1584
+ "kv",
1585
+ { meta: { status: response.status, statusText: response.statusText } }
1586
+ ));
1587
+ }
1588
+ const quotaError = this.handleQuotaErrorResponse(response, errorText, key);
1589
+ if (quotaError) return quotaError;
1590
+ return err(serviceError(
1591
+ ErrorCodes.KV_WRITE_FAILED,
1592
+ `Failed to put key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
1593
+ "kv",
1594
+ { meta: { status: response.status, statusText: response.statusText } }
1595
+ ));
1596
+ }
1597
+ return ok({
1598
+ data: void 0,
1599
+ headers: this.createResponseHeaders(response.headers)
1600
+ });
1601
+ }
1546
1602
  /**
1547
1603
  * Store multiple values in one TinyCloud KV invocation.
1548
1604
  */
1549
1605
  async batchPut(items, options) {
1550
1606
  return this.withTelemetry("batchPut", String(items.length), async () => {
1607
+ if (this.readThroughReady !== null) await this.readThroughReady;
1551
1608
  if (!this.requireAuth()) {
1552
1609
  return err(authRequiredError("kv"));
1553
1610
  }
@@ -1578,155 +1635,177 @@ var KVService = class extends BaseService {
1578
1635
  }
1579
1636
  seen.add(path);
1580
1637
  }
1581
- let requestMayHaveDispatched = false;
1582
1638
  const request = this.createRequestSignal(options?.signal, options?.timeout);
1583
1639
  try {
1584
- const body = new FormData();
1585
- for (let index = 0; index < items.length; index++) {
1586
- body.append(
1587
- encodeKvBatchPartName(paths[index]),
1588
- this.serializeBatchPutValue(items[index])
1589
- );
1590
- }
1591
- const headers = this.context.invokeAny(
1592
- session,
1593
- paths.map((path) => ({
1594
- spaceId: session.spaceId,
1595
- service: "kv",
1596
- path,
1597
- action: KVAction.PUT
1598
- }))
1640
+ const network = () => this.batchPutToNetwork(items, paths, request.signal);
1641
+ if (this.readThrough === null) return await network();
1642
+ const entries = items.map((item, index) => ({
1643
+ path: paths[index],
1644
+ body: this.serializeBatchPutValue(item)
1645
+ }));
1646
+ return await this.readThrough.write({
1647
+ op: "batchPut",
1648
+ space: session.spaceId,
1649
+ entries,
1650
+ signal: request.signal,
1651
+ network: () => this.batchPutToNetwork(items, paths, request.signal, entries)
1652
+ });
1653
+ } catch (error) {
1654
+ const wrapped = wrapError("kv", error);
1655
+ return err({ ...wrapped, meta: { ...wrapped.meta, requestMayHaveDispatched: false } });
1656
+ } finally {
1657
+ request.dispose();
1658
+ }
1659
+ });
1660
+ }
1661
+ async batchPutToNetwork(items, paths, signal, entries) {
1662
+ const session = this.context.session;
1663
+ let requestMayHaveDispatched = false;
1664
+ try {
1665
+ const body = new FormData();
1666
+ for (let index = 0; index < items.length; index++) {
1667
+ body.append(
1668
+ encodeKvBatchPartName(paths[index]),
1669
+ entries?.[index]?.body ?? this.serializeBatchPutValue(items[index])
1599
1670
  );
1600
- const url = `${this.host}/invoke`;
1601
- const init = {
1602
- method: "POST",
1603
- headers,
1604
- body,
1605
- signal: request.signal
1606
- };
1607
- const fetchFn = this.context.fetch;
1608
- requestMayHaveDispatched = true;
1609
- const response = await fetchFn(url, init);
1610
- if (!response.ok) {
1611
- let errorText;
1612
- try {
1613
- errorText = await response.text();
1614
- } catch (textError) {
1615
- const cause = textError instanceof Error ? textError : new Error(String(textError));
1616
- return err(
1617
- serviceError(
1618
- ErrorCodes.KV_WRITE_FAILED,
1619
- `Failed to batch put ${items.length} key(s): ${response.status} - <response body could not be read: ${cause.message}>`,
1620
- "kv",
1621
- { cause, meta: { status: response.status, statusText: response.statusText } }
1622
- )
1623
- );
1624
- }
1625
- if (response.status === 401 || response.status === 403) {
1626
- return this.authorizationFailure(
1627
- `Failed to batch put ${items.length} key(s)`,
1628
- response,
1629
- errorText,
1630
- paths,
1631
- KVAction.PUT
1632
- );
1633
- }
1634
- const quotaError = this.handleQuotaErrorResponse(
1635
- response,
1636
- errorText,
1637
- "batch"
1638
- );
1639
- if (quotaError) {
1640
- return quotaError;
1641
- }
1671
+ }
1672
+ const headers = this.context.invokeAny(
1673
+ session,
1674
+ paths.map((path) => ({
1675
+ spaceId: session.spaceId,
1676
+ service: "kv",
1677
+ path,
1678
+ action: KVAction.PUT
1679
+ }))
1680
+ );
1681
+ const url = `${this.host}/invoke`;
1682
+ const init = {
1683
+ method: "POST",
1684
+ headers,
1685
+ body,
1686
+ signal
1687
+ };
1688
+ const fetchFn = this.context.fetch;
1689
+ requestMayHaveDispatched = true;
1690
+ const response = await fetchFn(url, init);
1691
+ if (!response.ok) {
1692
+ let errorText;
1693
+ try {
1694
+ errorText = await response.text();
1695
+ } catch (textError) {
1696
+ const cause = textError instanceof Error ? textError : new Error(String(textError));
1642
1697
  return err(
1643
1698
  serviceError(
1644
1699
  ErrorCodes.KV_WRITE_FAILED,
1645
- `Failed to batch put ${items.length} key(s): ${response.status} - ${errorText}`,
1700
+ `Failed to batch put ${items.length} key(s): ${response.status} - <response body could not be read: ${cause.message}>`,
1646
1701
  "kv",
1647
- { meta: { status: response.status, statusText: response.statusText } }
1702
+ { cause, meta: { status: response.status, statusText: response.statusText } }
1648
1703
  )
1649
1704
  );
1650
1705
  }
1651
- let rawBody;
1652
- try {
1653
- rawBody = await response.json();
1654
- } catch (jsonError) {
1655
- const cause = jsonError instanceof Error ? jsonError : new Error(String(jsonError));
1656
- const timedOut = isRequestTimeoutError(cause);
1657
- return err(
1658
- serviceError(
1659
- timedOut ? ErrorCodes.TIMEOUT : ErrorCodes.NETWORK_ERROR,
1660
- timedOut ? `KV batchPut response body did not arrive in time: ${cause.message}` : `KV batchPut response was not valid JSON: ${cause.message}`,
1661
- "kv",
1662
- {
1663
- cause,
1664
- meta: {
1665
- ...timedOut ? { timeoutMs: cause.timeoutMs } : {},
1666
- requestMayHaveDispatched: true,
1667
- responseReceived: true,
1668
- status: response.status,
1669
- outcome: "batch-unconfirmed"
1670
- }
1671
- }
1672
- )
1706
+ if (response.status === 401 || response.status === 403) {
1707
+ return this.authorizationFailure(
1708
+ `Failed to batch put ${items.length} key(s)`,
1709
+ response,
1710
+ errorText,
1711
+ paths,
1712
+ KVAction.PUT
1673
1713
  );
1674
1714
  }
1675
- const batchResponse = this.normalizeBatchPutResponse(rawBody);
1676
- if (!batchResponse) {
1677
- return err(
1678
- serviceError(
1679
- ErrorCodes.NETWORK_ERROR,
1680
- "KV batchPut response did not include matching written keys and count",
1681
- "kv",
1682
- {
1683
- meta: {
1684
- requestMayHaveDispatched: true,
1685
- responseReceived: true,
1686
- status: response.status,
1687
- outcome: "batch-unconfirmed"
1688
- }
1689
- }
1690
- )
1691
- );
1715
+ const quotaError = this.handleQuotaErrorResponse(
1716
+ response,
1717
+ errorText,
1718
+ "batch"
1719
+ );
1720
+ if (quotaError) {
1721
+ return quotaError;
1692
1722
  }
1693
- const requestedPaths = new Set(paths);
1694
- const writtenPaths = new Set(batchResponse.written);
1695
- const matchesRequest = batchResponse.count === paths.length && batchResponse.written.length === paths.length && writtenPaths.size === paths.length && batchResponse.written.every((key) => requestedPaths.has(key));
1696
- if (!matchesRequest) {
1697
- return err(
1698
- serviceError(
1699
- ErrorCodes.NETWORK_ERROR,
1700
- `KV batchPut response did not confirm all ${paths.length} requested key(s) were written`,
1701
- "kv",
1702
- {
1703
- meta: {
1704
- requestMayHaveDispatched: true,
1705
- responseReceived: true,
1706
- status: response.status,
1707
- outcome: "batch-unconfirmed"
1708
- }
1723
+ return err(
1724
+ serviceError(
1725
+ ErrorCodes.KV_WRITE_FAILED,
1726
+ `Failed to batch put ${items.length} key(s): ${response.status} - ${errorText}`,
1727
+ "kv",
1728
+ { meta: { status: response.status, statusText: response.statusText } }
1729
+ )
1730
+ );
1731
+ }
1732
+ let rawBody;
1733
+ try {
1734
+ rawBody = await response.json();
1735
+ } catch (jsonError) {
1736
+ const cause = jsonError instanceof Error ? jsonError : new Error(String(jsonError));
1737
+ const timedOut = isRequestTimeoutError(cause);
1738
+ return err(
1739
+ serviceError(
1740
+ timedOut ? ErrorCodes.TIMEOUT : ErrorCodes.NETWORK_ERROR,
1741
+ timedOut ? `KV batchPut response body did not arrive in time: ${cause.message}` : `KV batchPut response was not valid JSON: ${cause.message}`,
1742
+ "kv",
1743
+ {
1744
+ cause,
1745
+ meta: {
1746
+ ...timedOut ? { timeoutMs: cause.timeoutMs } : {},
1747
+ requestMayHaveDispatched: true,
1748
+ responseReceived: true,
1749
+ status: response.status,
1750
+ outcome: "batch-unconfirmed"
1709
1751
  }
1710
- )
1711
- );
1712
- }
1713
- return ok(batchResponse);
1714
- } catch (error) {
1715
- const wrapped = wrapError("kv", error);
1716
- return err({
1717
- ...wrapped,
1718
- meta: { ...wrapped.meta, requestMayHaveDispatched }
1719
- });
1720
- } finally {
1721
- request.dispose();
1752
+ }
1753
+ )
1754
+ );
1722
1755
  }
1723
- });
1756
+ const batchResponse = this.normalizeBatchPutResponse(rawBody);
1757
+ if (!batchResponse) {
1758
+ return err(
1759
+ serviceError(
1760
+ ErrorCodes.NETWORK_ERROR,
1761
+ "KV batchPut response did not include matching written keys and count",
1762
+ "kv",
1763
+ {
1764
+ meta: {
1765
+ requestMayHaveDispatched: true,
1766
+ responseReceived: true,
1767
+ status: response.status,
1768
+ outcome: "batch-unconfirmed"
1769
+ }
1770
+ }
1771
+ )
1772
+ );
1773
+ }
1774
+ const requestedPaths = new Set(paths);
1775
+ const writtenPaths = new Set(batchResponse.written);
1776
+ const matchesRequest = batchResponse.count === paths.length && batchResponse.written.length === paths.length && writtenPaths.size === paths.length && batchResponse.written.every((key) => requestedPaths.has(key));
1777
+ if (!matchesRequest) {
1778
+ return err(
1779
+ serviceError(
1780
+ ErrorCodes.NETWORK_ERROR,
1781
+ `KV batchPut response did not confirm all ${paths.length} requested key(s) were written`,
1782
+ "kv",
1783
+ {
1784
+ meta: {
1785
+ requestMayHaveDispatched: true,
1786
+ responseReceived: true,
1787
+ status: response.status,
1788
+ outcome: "batch-unconfirmed"
1789
+ }
1790
+ }
1791
+ )
1792
+ );
1793
+ }
1794
+ return ok(batchResponse);
1795
+ } catch (error) {
1796
+ const wrapped = wrapError("kv", error);
1797
+ return err({
1798
+ ...wrapped,
1799
+ meta: { ...wrapped.meta, requestMayHaveDispatched }
1800
+ });
1801
+ }
1724
1802
  }
1725
1803
  /**
1726
1804
  * List keys with optional prefix filtering.
1727
1805
  */
1728
1806
  async list(options) {
1729
1807
  return this.withTelemetry("list", options?.prefix, async () => {
1808
+ if (this.readThroughReady !== null) await this.readThroughReady;
1730
1809
  if (!this.requireAuth()) {
1731
1810
  return err(authRequiredError("kv"));
1732
1811
  }
@@ -1743,49 +1822,44 @@ var KVService = class extends BaseService {
1743
1822
  }
1744
1823
  const request = this.createRequestSignal(options?.signal, options?.timeout);
1745
1824
  try {
1746
- const response = await this.invokeOperation(
1747
- listPath,
1748
- KVAction.LIST,
1749
- void 0,
1750
- request.signal,
1751
- options?.limit === void 0 && options?.cursor === void 0 ? void 0 : {
1752
- ...options?.limit === void 0 ? {} : { "x-tinycloud-limit": String(options.limit) },
1753
- ...options?.cursor === void 0 ? {} : { "x-tinycloud-cursor": options.cursor }
1754
- }
1755
- );
1756
- if (!response.ok) {
1757
- if (response.status === 401 || response.status === 403) {
1758
- return this.authorizationFailure(
1759
- "Failed to list keys",
1760
- response,
1761
- await this.readAuthorizationText(response),
1762
- [listPath],
1763
- KVAction.LIST
1764
- );
1825
+ const network = () => this.listFromNetwork(listPath, options, request.signal);
1826
+ let page;
1827
+ if (options?.source === "network") {
1828
+ const observer = this.readThrough;
1829
+ if (observer === null) {
1830
+ page = await network();
1831
+ } else {
1832
+ let outcome = "error";
1833
+ const startedAt = Date.now();
1834
+ try {
1835
+ page = await network();
1836
+ outcome = page.ok ? "found" : "error";
1837
+ } finally {
1838
+ try {
1839
+ observer.observeNetworkRequested({
1840
+ op: "list",
1841
+ space: this.context.session.spaceId,
1842
+ path: listPath,
1843
+ reason: "NETWORK_REQUESTED",
1844
+ outcome,
1845
+ latencyMs: Date.now() - startedAt
1846
+ });
1847
+ } catch {
1848
+ }
1849
+ }
1765
1850
  }
1766
- const errorText = await response.text();
1767
- return err(
1768
- serviceError(
1769
- ErrorCodes.NETWORK_ERROR,
1770
- `Failed to list keys: ${response.status} - ${errorText}`,
1771
- "kv",
1772
- { meta: { status: response.status, statusText: response.statusText } }
1773
- )
1774
- );
1851
+ } else if (this.readThrough === null) {
1852
+ page = await network();
1853
+ } else {
1854
+ page = await this.readThrough.list({
1855
+ space: this.context.session.spaceId,
1856
+ listPath,
1857
+ options,
1858
+ signal: request.signal,
1859
+ network
1860
+ });
1775
1861
  }
1776
- let keys = await this.parseResponse(response, options?.raw);
1777
- keys = keys ?? [];
1778
- if (options?.removePrefix && listPath) {
1779
- const prefixWithSlash = listPath.endsWith("/") ? listPath : `${listPath}/`;
1780
- keys = keys.map(
1781
- (key) => key.startsWith(prefixWithSlash) ? key.slice(prefixWithSlash.length) : key
1782
- );
1783
- }
1784
- return ok({
1785
- keys,
1786
- ...response.headers.get("x-tinycloud-truncated") === null ? {} : { truncated: response.headers.get("x-tinycloud-truncated") === "true" },
1787
- ...response.headers.get("x-tinycloud-next-cursor") === null ? {} : { nextCursor: response.headers.get("x-tinycloud-next-cursor") ?? void 0 }
1788
- });
1862
+ return page.ok ? this.finishList(page.data, listPath, options?.removePrefix) : page;
1789
1863
  } catch (error) {
1790
1864
  return err(wrapError("kv", error));
1791
1865
  } finally {
@@ -1793,6 +1867,56 @@ var KVService = class extends BaseService {
1793
1867
  }
1794
1868
  });
1795
1869
  }
1870
+ async listFromNetwork(listPath, options, signal) {
1871
+ const response = await this.invokeOperation(
1872
+ listPath,
1873
+ KVAction.LIST,
1874
+ void 0,
1875
+ signal,
1876
+ options?.limit === void 0 && options?.cursor === void 0 ? void 0 : {
1877
+ ...options?.limit === void 0 ? {} : { "x-tinycloud-limit": String(options.limit) },
1878
+ ...options?.cursor === void 0 ? {} : { "x-tinycloud-cursor": options.cursor }
1879
+ }
1880
+ );
1881
+ if (!response.ok) {
1882
+ if (response.status === 401 || response.status === 403) {
1883
+ return this.authorizationFailure(
1884
+ "Failed to list keys",
1885
+ response,
1886
+ await this.readAuthorizationText(response),
1887
+ [listPath],
1888
+ KVAction.LIST
1889
+ );
1890
+ }
1891
+ const errorText = await response.text();
1892
+ return err(serviceError(
1893
+ ErrorCodes.NETWORK_ERROR,
1894
+ `Failed to list keys: ${response.status} - ${errorText}`,
1895
+ "kv",
1896
+ { meta: { status: response.status, statusText: response.statusText } }
1897
+ ));
1898
+ }
1899
+ const keys = await this.parseResponse(response, options?.raw) ?? [];
1900
+ return ok({
1901
+ keys,
1902
+ ...response.headers.get("x-tinycloud-truncated") === null ? {} : { truncated: response.headers.get("x-tinycloud-truncated") === "true" },
1903
+ ...response.headers.get("x-tinycloud-next-cursor") === null ? {} : { nextCursor: response.headers.get("x-tinycloud-next-cursor") ?? void 0 }
1904
+ });
1905
+ }
1906
+ finishList(page, listPath, removePrefix) {
1907
+ let keys = page.keys;
1908
+ if (removePrefix && listPath) {
1909
+ const prefixWithSlash = listPath.endsWith("/") ? listPath : `${listPath}/`;
1910
+ keys = keys.map(
1911
+ (key) => key.startsWith(prefixWithSlash) ? key.slice(prefixWithSlash.length) : key
1912
+ );
1913
+ }
1914
+ return ok({
1915
+ keys,
1916
+ ...page.truncated === void 0 ? {} : { truncated: page.truncated },
1917
+ ...page.nextCursor === void 0 ? {} : { nextCursor: page.nextCursor }
1918
+ });
1919
+ }
1796
1920
  /**
1797
1921
  * Read one page of the `tinycloud.kv/sync` change feed for a prefix.
1798
1922
  */
@@ -1961,61 +2085,22 @@ var KVService = class extends BaseService {
1961
2085
  */
1962
2086
  async delete(key, options) {
1963
2087
  return this.withTelemetry("delete", key, async () => {
2088
+ if (this.readThroughReady !== null) await this.readThroughReady;
1964
2089
  if (!this.requireAuth()) {
1965
2090
  return err(authRequiredError("kv"));
1966
2091
  }
1967
2092
  const path = this.getFullPath(key, options?.prefix);
1968
2093
  const request = this.createRequestSignal(options?.signal, options?.timeout);
1969
2094
  try {
1970
- const response = await this.invokeOperation(
1971
- path,
1972
- KVAction.DELETE,
1973
- void 0,
1974
- request.signal,
1975
- options?.ifMatch === void 0 ? void 0 : { "if-match": options.ifMatch }
1976
- );
1977
- if (!response.ok) {
1978
- if (response.status === 401 || response.status === 403) {
1979
- return this.authorizationFailure(
1980
- `Failed to delete key ${JSON.stringify(key)}`,
1981
- response,
1982
- await this.readAuthorizationText(response),
1983
- [path],
1984
- KVAction.DELETE
1985
- );
1986
- }
1987
- if (response.status === 404) {
1988
- return this.classifyNotFound(response, key);
1989
- }
1990
- const errorText = await response.text();
1991
- if (response.status === 412) {
1992
- return err(serviceError(
1993
- ErrorCodes.KV_PRECONDITION_FAILED,
1994
- `KV precondition failed for key ${JSON.stringify(key)}`,
1995
- "kv",
1996
- { meta: { status: response.status, statusText: response.statusText } }
1997
- ));
1998
- }
1999
- if (response.status === 503 && options?.ifMatch !== void 0) {
2000
- return err(serviceError(
2001
- ErrorCodes.KV_CONFLICT,
2002
- `Concurrent KV delete conflicted for key ${JSON.stringify(key)}`,
2003
- "kv",
2004
- { meta: { status: response.status, statusText: response.statusText } }
2005
- ));
2006
- }
2007
- return err(
2008
- serviceError(
2009
- ErrorCodes.NETWORK_ERROR,
2010
- `Failed to delete key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
2011
- "kv",
2012
- { meta: { status: response.status, statusText: response.statusText } }
2013
- )
2014
- );
2015
- }
2016
- return ok({
2017
- data: void 0,
2018
- headers: this.createResponseHeaders(response.headers)
2095
+ const network = () => this.deleteFromNetwork(key, path, options, request.signal);
2096
+ if (this.readThrough === null) return await network();
2097
+ return await this.readThrough.write({
2098
+ op: "delete",
2099
+ space: this.context.session.spaceId,
2100
+ entries: [{ path }],
2101
+ signal: request.signal,
2102
+ conditional: options?.ifMatch !== void 0,
2103
+ network
2019
2104
  });
2020
2105
  } catch (error) {
2021
2106
  return err(wrapError("kv", error));
@@ -2024,6 +2109,54 @@ var KVService = class extends BaseService {
2024
2109
  }
2025
2110
  });
2026
2111
  }
2112
+ async deleteFromNetwork(key, path, options, signal) {
2113
+ const response = await this.invokeOperation(
2114
+ path,
2115
+ KVAction.DELETE,
2116
+ void 0,
2117
+ signal,
2118
+ options?.ifMatch === void 0 ? void 0 : { "if-match": options.ifMatch }
2119
+ );
2120
+ if (!response.ok) {
2121
+ if (response.status === 401 || response.status === 403) {
2122
+ return this.authorizationFailure(
2123
+ `Failed to delete key ${JSON.stringify(key)}`,
2124
+ response,
2125
+ await this.readAuthorizationText(response),
2126
+ [path],
2127
+ KVAction.DELETE
2128
+ );
2129
+ }
2130
+ if (response.status === 404) return this.classifyNotFound(response, key);
2131
+ const errorText = await response.text();
2132
+ if (response.status === 412) {
2133
+ return err(serviceError(
2134
+ ErrorCodes.KV_PRECONDITION_FAILED,
2135
+ `KV precondition failed for key ${JSON.stringify(key)}`,
2136
+ "kv",
2137
+ { meta: { status: response.status, statusText: response.statusText } }
2138
+ ));
2139
+ }
2140
+ if (response.status === 503 && options?.ifMatch !== void 0) {
2141
+ return err(serviceError(
2142
+ ErrorCodes.KV_CONFLICT,
2143
+ `Concurrent KV delete conflicted for key ${JSON.stringify(key)}`,
2144
+ "kv",
2145
+ { meta: { status: response.status, statusText: response.statusText } }
2146
+ ));
2147
+ }
2148
+ return err(serviceError(
2149
+ ErrorCodes.NETWORK_ERROR,
2150
+ `Failed to delete key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
2151
+ "kv",
2152
+ { meta: { status: response.status, statusText: response.statusText } }
2153
+ ));
2154
+ }
2155
+ return ok({
2156
+ data: void 0,
2157
+ headers: this.createResponseHeaders(response.headers)
2158
+ });
2159
+ }
2027
2160
  /**
2028
2161
  * Get metadata for a key without retrieving the value.
2029
2162
  */
@@ -2052,7 +2185,7 @@ var KVService = class extends BaseService {
2052
2185
  );
2053
2186
  }
2054
2187
  if (response.status === 404) {
2055
- return this.classifyNotFound(response, key);
2188
+ return await this.classifyNotFound(response, key);
2056
2189
  }
2057
2190
  const errorText = await response.text();
2058
2191
  return err(
@@ -2118,7 +2251,7 @@ var KVService = class extends BaseService {
2118
2251
  signal: request.signal
2119
2252
  });
2120
2253
  if (!response.ok) {
2121
- return this.createSignedReadUrlError(response, key, path);
2254
+ return await this.createSignedReadUrlError(response, key, path);
2122
2255
  }
2123
2256
  const signedUrl = this.normalizeSignedReadUrlResponse(
2124
2257
  await response.json()
@@ -2190,11 +2323,74 @@ var KVService = class extends BaseService {
2190
2323
  * Service identifier for registration.
2191
2324
  */
2192
2325
  KVService.serviceName = "kv";
2326
+
2327
+ // src/kv/replication/constants.ts
2328
+ var CLEAR_PENDING_WARNING = "Clearing pending writes surrenders read-your-writes protection for the cleared keys. If a write whose record was cleared had an unknown outcome or never finished, it may commit now or later; this device can serve an older value until the next sync after that commit, as it would for a write from another device.";
2329
+
2330
+ // src/kv/replication/identity.ts
2331
+ var SPACE_ID = /^(tinycloud:pkh:eip155:\d+:)([^:]+)(:.+)$/;
2332
+ var PRINCIPAL_DID = /^(did:pkh:eip155:\d+:)([^:]+)$/;
2333
+ function canonicalReplicationIdentity(raw) {
2334
+ if (!raw || typeof raw.host !== "string" || typeof raw.space !== "string" || typeof raw.principal !== "string") {
2335
+ throw new TypeError(
2336
+ "Replication identity host, space, and principal must be strings"
2337
+ );
2338
+ }
2339
+ const authority = /^https?:\/\/([^/?#]*)/i.exec(raw.host)?.[1];
2340
+ let url;
2341
+ try {
2342
+ url = new URL(raw.host);
2343
+ } catch {
2344
+ throw new TypeError(
2345
+ "Replication identity host must be an absolute HTTP(S) URL"
2346
+ );
2347
+ }
2348
+ if (!authority || authority.includes("@") || raw.host.includes("?") || raw.host.includes("#") || url.username || url.password) {
2349
+ throw new TypeError(
2350
+ "Replication identity host must be HTTP(S) without credentials, query, or fragment"
2351
+ );
2352
+ }
2353
+ const space = SPACE_ID.exec(raw.space);
2354
+ const principal = PRINCIPAL_DID.exec(raw.principal);
2355
+ if (!space || !principal) {
2356
+ throw new TypeError(
2357
+ "Replication identity requires a full TinyCloud space id and a pkh principal DID"
2358
+ );
2359
+ }
2360
+ return {
2361
+ host: `${url.origin}${url.pathname.replace(/\/+$/, "")}`,
2362
+ space: `${space[1]}${space[2].toLowerCase()}${space[3]}`,
2363
+ principal: `${principal[1]}${principal[2].toLowerCase()}`
2364
+ };
2365
+ }
2366
+ function replicationIdentityKey(id) {
2367
+ const canonical = canonicalReplicationIdentity(id);
2368
+ return `tc-replication/v1
2369
+ ${canonical.host}
2370
+ ${canonical.space}
2371
+ ${canonical.principal}`;
2372
+ }
2373
+
2374
+ // src/kv/replication/scope.ts
2375
+ function kvPrefixCovers(prefix, key) {
2376
+ if (prefix === "") return true;
2377
+ if (prefix.endsWith("/")) return key.startsWith(prefix);
2378
+ return key === prefix || key.startsWith(`${prefix}/`);
2379
+ }
2380
+ function requiresSecretsOptIn(space, prefix) {
2381
+ if (space === "secrets" || space.endsWith(":secrets")) return true;
2382
+ return prefix.split("/", 1)[0] === "vault";
2383
+ }
2193
2384
  // Annotate the CommonJS export names for ESM import in node:
2194
2385
  0 && (module.exports = {
2386
+ CLEAR_PENDING_WARNING,
2195
2387
  DEFAULT_SIGNED_READ_URL_EXPIRY_MS,
2196
2388
  KVAction,
2197
2389
  KVService,
2198
- PrefixedKVService
2390
+ PrefixedKVService,
2391
+ canonicalReplicationIdentity,
2392
+ kvPrefixCovers,
2393
+ replicationIdentityKey,
2394
+ requiresSecretsOptIn
2199
2395
  });
2200
2396
  //# sourceMappingURL=index.cjs.map