@tinycloud/sdk-services 3.2.0-beta.2 → 3.2.0-beta.5

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 (45) 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 +605 -357
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +37 -26
  12. package/dist/index.d.ts +37 -26
  13. package/dist/index.js +600 -357
  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 +540 -338
  18. package/dist/kv/index.cjs.map +1 -1
  19. package/dist/kv/index.d.cts +18 -468
  20. package/dist/kv/index.d.ts +18 -468
  21. package/dist/kv/index.js +534 -337
  22. package/dist/kv/index.js.map +1 -1
  23. package/dist/kv/replication/index.cjs +1398 -0
  24. package/dist/kv/replication/index.cjs.map +1 -0
  25. package/dist/kv/replication/index.d.cts +10 -0
  26. package/dist/kv/replication/index.d.ts +10 -0
  27. package/dist/kv/replication/index.js +1365 -0
  28. package/dist/kv/replication/index.js.map +1 -0
  29. package/dist/kv/replication/prefix.cjs +43 -0
  30. package/dist/kv/replication/prefix.cjs.map +1 -0
  31. package/dist/kv/replication/prefix.d.cts +6 -0
  32. package/dist/kv/replication/prefix.d.ts +6 -0
  33. package/dist/kv/replication/prefix.js +17 -0
  34. package/dist/kv/replication/prefix.js.map +1 -0
  35. package/dist/scope-BKge7zDT.d.ts +957 -0
  36. package/dist/scope-C05VVAXH.d.cts +957 -0
  37. package/dist/sql/index.cjs +12 -6
  38. package/dist/sql/index.cjs.map +1 -1
  39. package/dist/sql/index.d.cts +2 -2
  40. package/dist/sql/index.d.ts +2 -2
  41. package/dist/sql/index.js +12 -6
  42. package/dist/sql/index.js.map +1 -1
  43. package/dist/{types-CVwwxqXF.d.cts → types-7A5rHWRY.d.cts} +10 -1
  44. package/dist/{types-CVwwxqXF.d.ts → types-7A5rHWRY.d.ts} +10 -1
  45. package/package.json +31 -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 await 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
- );
1775
- }
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
- );
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
+ });
1783
1861
  }
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
  */
@@ -1815,6 +1939,7 @@ var KVService = class extends BaseService {
1815
1939
  "kv"
1816
1940
  ));
1817
1941
  }
1942
+ const requestPrefix = options.prefix === "/" ? "" : options.prefix;
1818
1943
  const headers = {};
1819
1944
  if (options.limit !== void 0) headers["x-tinycloud-limit"] = String(options.limit);
1820
1945
  if (options.cursor !== void 0) headers["x-tinycloud-cursor"] = options.cursor;
@@ -1824,7 +1949,7 @@ var KVService = class extends BaseService {
1824
1949
  const request = this.createRequestSignal(options.signal, options.timeout);
1825
1950
  try {
1826
1951
  const response = await this.invokeOperation(
1827
- options.prefix,
1952
+ requestPrefix,
1828
1953
  KVAction.SYNC,
1829
1954
  void 0,
1830
1955
  request.signal,
@@ -1840,7 +1965,7 @@ var KVService = class extends BaseService {
1840
1965
  } catch {
1841
1966
  body = void 0;
1842
1967
  }
1843
- const page = this.normalizeChangesResponse(body);
1968
+ const page = this.normalizeChangesResponse(body, options.prefix);
1844
1969
  if (!page) {
1845
1970
  return err(serviceError(
1846
1971
  ErrorCodes.NETWORK_ERROR,
@@ -1920,7 +2045,7 @@ var KVService = class extends BaseService {
1920
2045
  { meta }
1921
2046
  ));
1922
2047
  }
1923
- normalizeChangesResponse(data) {
2048
+ normalizeChangesResponse(data, requestedPrefix) {
1924
2049
  if (!isJsonObject(data)) return void 0;
1925
2050
  const { changes, more, cursor, source, authority } = data;
1926
2051
  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)) {
@@ -1952,7 +2077,11 @@ var KVService = class extends BaseService {
1952
2077
  changes: parsed,
1953
2078
  more,
1954
2079
  cursor,
1955
- source: { nodeDid: source.nodeDid, space: source.space, prefix: source.prefix },
2080
+ source: {
2081
+ nodeDid: source.nodeDid,
2082
+ space: source.space,
2083
+ prefix: requestedPrefix === "/" && source.prefix === "" ? "/" : source.prefix
2084
+ },
1956
2085
  authority: { notBefore, expiresAt, retainUntil }
1957
2086
  };
1958
2087
  }
@@ -1961,61 +2090,22 @@ var KVService = class extends BaseService {
1961
2090
  */
1962
2091
  async delete(key, options) {
1963
2092
  return this.withTelemetry("delete", key, async () => {
2093
+ if (this.readThroughReady !== null) await this.readThroughReady;
1964
2094
  if (!this.requireAuth()) {
1965
2095
  return err(authRequiredError("kv"));
1966
2096
  }
1967
2097
  const path = this.getFullPath(key, options?.prefix);
1968
2098
  const request = this.createRequestSignal(options?.signal, options?.timeout);
1969
2099
  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 await 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)
2100
+ const network = () => this.deleteFromNetwork(key, path, options, request.signal);
2101
+ if (this.readThrough === null) return await network();
2102
+ return await this.readThrough.write({
2103
+ op: "delete",
2104
+ space: this.context.session.spaceId,
2105
+ entries: [{ path }],
2106
+ signal: request.signal,
2107
+ conditional: options?.ifMatch !== void 0,
2108
+ network
2019
2109
  });
2020
2110
  } catch (error) {
2021
2111
  return err(wrapError("kv", error));
@@ -2024,6 +2114,54 @@ var KVService = class extends BaseService {
2024
2114
  }
2025
2115
  });
2026
2116
  }
2117
+ async deleteFromNetwork(key, path, options, signal) {
2118
+ const response = await this.invokeOperation(
2119
+ path,
2120
+ KVAction.DELETE,
2121
+ void 0,
2122
+ signal,
2123
+ options?.ifMatch === void 0 ? void 0 : { "if-match": options.ifMatch }
2124
+ );
2125
+ if (!response.ok) {
2126
+ if (response.status === 401 || response.status === 403) {
2127
+ return this.authorizationFailure(
2128
+ `Failed to delete key ${JSON.stringify(key)}`,
2129
+ response,
2130
+ await this.readAuthorizationText(response),
2131
+ [path],
2132
+ KVAction.DELETE
2133
+ );
2134
+ }
2135
+ if (response.status === 404) return this.classifyNotFound(response, key);
2136
+ const errorText = await response.text();
2137
+ if (response.status === 412) {
2138
+ return err(serviceError(
2139
+ ErrorCodes.KV_PRECONDITION_FAILED,
2140
+ `KV precondition failed for key ${JSON.stringify(key)}`,
2141
+ "kv",
2142
+ { meta: { status: response.status, statusText: response.statusText } }
2143
+ ));
2144
+ }
2145
+ if (response.status === 503 && options?.ifMatch !== void 0) {
2146
+ return err(serviceError(
2147
+ ErrorCodes.KV_CONFLICT,
2148
+ `Concurrent KV delete conflicted for key ${JSON.stringify(key)}`,
2149
+ "kv",
2150
+ { meta: { status: response.status, statusText: response.statusText } }
2151
+ ));
2152
+ }
2153
+ return err(serviceError(
2154
+ ErrorCodes.NETWORK_ERROR,
2155
+ `Failed to delete key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
2156
+ "kv",
2157
+ { meta: { status: response.status, statusText: response.statusText } }
2158
+ ));
2159
+ }
2160
+ return ok({
2161
+ data: void 0,
2162
+ headers: this.createResponseHeaders(response.headers)
2163
+ });
2164
+ }
2027
2165
  /**
2028
2166
  * Get metadata for a key without retrieving the value.
2029
2167
  */
@@ -2190,11 +2328,75 @@ var KVService = class extends BaseService {
2190
2328
  * Service identifier for registration.
2191
2329
  */
2192
2330
  KVService.serviceName = "kv";
2331
+
2332
+ // src/kv/replication/constants.ts
2333
+ 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.";
2334
+
2335
+ // src/kv/replication/identity.ts
2336
+ var SPACE_ID = /^(tinycloud:pkh:eip155:\d+:)([^:]+)(:.+)$/;
2337
+ var PRINCIPAL_DID = /^(did:pkh:eip155:\d+:)([^:]+)$/;
2338
+ function canonicalReplicationIdentity(raw) {
2339
+ if (!raw || typeof raw.host !== "string" || typeof raw.space !== "string" || typeof raw.principal !== "string") {
2340
+ throw new TypeError(
2341
+ "Replication identity host, space, and principal must be strings"
2342
+ );
2343
+ }
2344
+ const authority = /^https?:\/\/([^/?#]*)/i.exec(raw.host)?.[1];
2345
+ let url;
2346
+ try {
2347
+ url = new URL(raw.host);
2348
+ } catch {
2349
+ throw new TypeError(
2350
+ "Replication identity host must be an absolute HTTP(S) URL"
2351
+ );
2352
+ }
2353
+ if (!authority || authority.includes("@") || raw.host.includes("?") || raw.host.includes("#") || url.username || url.password) {
2354
+ throw new TypeError(
2355
+ "Replication identity host must be HTTP(S) without credentials, query, or fragment"
2356
+ );
2357
+ }
2358
+ const space = SPACE_ID.exec(raw.space);
2359
+ const principal = PRINCIPAL_DID.exec(raw.principal);
2360
+ if (!space || !principal) {
2361
+ throw new TypeError(
2362
+ "Replication identity requires a full TinyCloud space id and a pkh principal DID"
2363
+ );
2364
+ }
2365
+ return {
2366
+ host: `${url.origin}${url.pathname.replace(/\/+$/, "")}`,
2367
+ space: `${space[1]}${space[2].toLowerCase()}${space[3]}`,
2368
+ principal: `${principal[1]}${principal[2].toLowerCase()}`
2369
+ };
2370
+ }
2371
+ function replicationIdentityKey(id) {
2372
+ const canonical = canonicalReplicationIdentity(id);
2373
+ return `tc-replication/v1
2374
+ ${canonical.host}
2375
+ ${canonical.space}
2376
+ ${canonical.principal}`;
2377
+ }
2378
+
2379
+ // src/kv/replication/prefix.ts
2380
+ function kvPrefixCovers(prefix, key) {
2381
+ return prefix === "/" || key.startsWith(prefix);
2382
+ }
2383
+
2384
+ // src/kv/replication/scope.ts
2385
+ function requiresSecretsOptIn(space, prefix) {
2386
+ if (prefix === "/") return true;
2387
+ if (space === "secrets" || space.endsWith(":secrets")) return true;
2388
+ return prefix.split("/", 1)[0] === "vault";
2389
+ }
2193
2390
  // Annotate the CommonJS export names for ESM import in node:
2194
2391
  0 && (module.exports = {
2392
+ CLEAR_PENDING_WARNING,
2195
2393
  DEFAULT_SIGNED_READ_URL_EXPIRY_MS,
2196
2394
  KVAction,
2197
2395
  KVService,
2198
- PrefixedKVService
2396
+ PrefixedKVService,
2397
+ canonicalReplicationIdentity,
2398
+ kvPrefixCovers,
2399
+ replicationIdentityKey,
2400
+ requiresSecretsOptIn
2199
2401
  });
2200
2402
  //# sourceMappingURL=index.cjs.map