@tinycloud/sdk-services 3.2.0-beta.2 → 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 +595 -353
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +36 -26
  12. package/dist/index.d.ts +36 -26
  13. package/dist/index.js +590 -353
  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 +530 -334
  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 +524 -333
  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.js CHANGED
@@ -60,7 +60,12 @@ var TelemetryEvents = {
60
60
  SERVICE_ERROR: "service.error",
61
61
  SERVICE_RETRY: "service.retry",
62
62
  SESSION_CHANGED: "session.changed",
63
- SESSION_EXPIRED: "session.expired"
63
+ SESSION_EXPIRED: "session.expired",
64
+ REPLICATION_READ: "replication.read",
65
+ REPLICATION_WRITE: "replication.write",
66
+ REPLICATION_SYNC: "replication.sync",
67
+ REPLICATION_STATE: "replication.state",
68
+ REPLICATION_DIVERGENCE: "replication.divergence"
64
69
  };
65
70
  function ok(data) {
66
71
  return { ok: true, data };
@@ -588,6 +593,7 @@ var BaseService = class {
588
593
  const parents = [
589
594
  this.abortController.signal,
590
595
  this.context?.abortSignal,
596
+ this.context?.operationAbortSignal,
591
597
  signal
592
598
  ].filter((parent) => parent !== void 0);
593
599
  for (const parent of parents) {
@@ -833,14 +839,28 @@ var KVService = class extends BaseService {
833
839
  */
834
840
  constructor(config = {}) {
835
841
  super();
842
+ this.readThrough = null;
843
+ this.readThroughReady = null;
836
844
  this._config = config;
837
845
  }
846
+ /** Delay KV routing until an asynchronously loaded read-through is attached. */
847
+ setReadThroughReady(ready) {
848
+ const settled = ready.then(() => void 0, () => void 0);
849
+ this.readThroughReady = settled;
850
+ void settled.then(() => {
851
+ if (this.readThroughReady === settled) this.readThroughReady = null;
852
+ });
853
+ }
838
854
  /**
839
855
  * Get the service configuration.
840
856
  */
841
857
  get config() {
842
858
  return this._config;
843
859
  }
860
+ /** Install or remove the optional replication read-through policy. */
861
+ setReadThrough(readThrough) {
862
+ this.readThrough = readThrough;
863
+ }
844
864
  handleQuotaErrorResponse(response, errorText, key) {
845
865
  if (response.status !== 402 && response.status !== 413) {
846
866
  return void 0;
@@ -1094,6 +1114,7 @@ var KVService = class extends BaseService {
1094
1114
  return aligned;
1095
1115
  }
1096
1116
  async batchRead(keys, action, options) {
1117
+ if (this.readThroughReady !== null) await this.readThroughReady;
1097
1118
  if (!this.requireAuth()) return err(authRequiredError("kv"));
1098
1119
  if (keys.length === 0) return ok({ results: [], count: 0 });
1099
1120
  if (keys.length > MAX_KV_BATCH_READ_ITEMS) {
@@ -1344,6 +1365,7 @@ var KVService = class extends BaseService {
1344
1365
  */
1345
1366
  async get(key, options) {
1346
1367
  return this.withTelemetry("get", key, async () => {
1368
+ if (this.readThroughReady !== null) await this.readThroughReady;
1347
1369
  if (!this.requireAuth()) {
1348
1370
  return err(authRequiredError("kv"));
1349
1371
  }
@@ -1357,64 +1379,38 @@ var KVService = class extends BaseService {
1357
1379
  const path = this.getFullPath(key, options?.prefix);
1358
1380
  const request = this.createRequestSignal(options?.signal, options?.timeout);
1359
1381
  try {
1360
- const response = await this.invokeOperation(
1361
- path,
1362
- KVAction.GET,
1363
- void 0,
1364
- request.signal,
1365
- options?.maxResponseBytes === void 0 ? void 0 : { "x-tinycloud-max-response-bytes": String(options.maxResponseBytes) }
1366
- );
1367
- if (!response.ok) {
1368
- if (response.status === 401 || response.status === 403) {
1369
- const errorText2 = await this.readAuthorizationText(response);
1370
- const permissionHint = parsePermissionHintFromErrorText(errorText2);
1371
- const structuredCapability = permissionHint?.service === "tinycloud.kv" && permissionHint.space === this.context.session.spaceId && permissionHint.path === path ? validatedCapabilityOf({
1372
- service: "kv",
1373
- code: ErrorCodes.AUTH_UNAUTHORIZED,
1374
- meta: {
1375
- status: response.status,
1376
- resource: `${this.context.session.spaceId}/kv/${path}`,
1377
- requiredAction: KVAction.GET
1378
- }
1379
- }) : void 0;
1380
- return this.authorizationFailure(
1381
- `Failed to get key ${JSON.stringify(key)}`,
1382
- response,
1383
- errorText2,
1384
- [path],
1385
- KVAction.GET,
1386
- structuredCapability ? { permissionHint, ...structuredCapability } : {}
1387
- );
1388
- }
1389
- if (response.status === 404) {
1390
- return await this.classifyNotFound(response, key);
1391
- }
1392
- const errorText = await response.text();
1393
- if (response.status === 413) {
1394
- return err(serviceError(
1395
- ErrorCodes.KV_RESPONSE_TOO_LARGE,
1396
- `KV value at key ${JSON.stringify(key)} exceeds the requested response limit`,
1397
- "kv",
1398
- { meta: { status: response.status, statusText: response.statusText } }
1399
- ));
1382
+ const network = () => this.getFromNetwork(key, path, options, request.signal);
1383
+ if (options?.source === "network") {
1384
+ const observer = this.readThrough;
1385
+ if (observer === null) return await network();
1386
+ let outcome = "error";
1387
+ const startedAt = Date.now();
1388
+ try {
1389
+ const result = await network();
1390
+ outcome = result.ok ? "found" : result.error.code === ErrorCodes.KV_NOT_FOUND ? "not_found" : "error";
1391
+ return result;
1392
+ } finally {
1393
+ try {
1394
+ observer.observeNetworkRequested({
1395
+ op: "get",
1396
+ space: this.context.session.spaceId,
1397
+ path,
1398
+ reason: "NETWORK_REQUESTED",
1399
+ outcome,
1400
+ latencyMs: Date.now() - startedAt
1401
+ });
1402
+ } catch {
1403
+ }
1400
1404
  }
1401
- return err(
1402
- serviceError(
1403
- ErrorCodes.NETWORK_ERROR,
1404
- `Failed to get key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
1405
- "kv",
1406
- { meta: { status: response.status, statusText: response.statusText } }
1407
- )
1408
- );
1409
1405
  }
1410
- const data = await this.parseResponse(
1411
- response,
1412
- options?.raw,
1413
- options?.binary
1414
- );
1415
- return ok({
1416
- data,
1417
- headers: this.createResponseHeaders(response.headers)
1406
+ if (this.readThrough === null) return await network();
1407
+ return await this.readThrough.get({
1408
+ space: this.context.session.spaceId,
1409
+ key,
1410
+ path,
1411
+ options,
1412
+ signal: request.signal,
1413
+ network
1418
1414
  });
1419
1415
  } catch (error) {
1420
1416
  return err(wrapError("kv", error));
@@ -1423,6 +1419,56 @@ var KVService = class extends BaseService {
1423
1419
  }
1424
1420
  });
1425
1421
  }
1422
+ async getFromNetwork(key, path, options, signal) {
1423
+ const response = await this.invokeOperation(
1424
+ path,
1425
+ KVAction.GET,
1426
+ void 0,
1427
+ signal,
1428
+ options?.maxResponseBytes === void 0 ? void 0 : { "x-tinycloud-max-response-bytes": String(options.maxResponseBytes) }
1429
+ );
1430
+ if (!response.ok) {
1431
+ if (response.status === 401 || response.status === 403) {
1432
+ const errorText2 = await this.readAuthorizationText(response);
1433
+ const permissionHint = parsePermissionHintFromErrorText(errorText2);
1434
+ const structuredCapability = permissionHint?.service === "tinycloud.kv" && permissionHint.space === this.context.session.spaceId && permissionHint.path === path ? validatedCapabilityOf({
1435
+ service: "kv",
1436
+ code: ErrorCodes.AUTH_UNAUTHORIZED,
1437
+ meta: {
1438
+ status: response.status,
1439
+ resource: `${this.context.session.spaceId}/kv/${path}`,
1440
+ requiredAction: KVAction.GET
1441
+ }
1442
+ }) : void 0;
1443
+ return this.authorizationFailure(
1444
+ `Failed to get key ${JSON.stringify(key)}`,
1445
+ response,
1446
+ errorText2,
1447
+ [path],
1448
+ KVAction.GET,
1449
+ structuredCapability ? { permissionHint, ...structuredCapability } : {}
1450
+ );
1451
+ }
1452
+ if (response.status === 404) return this.classifyNotFound(response, key);
1453
+ const errorText = await response.text();
1454
+ if (response.status === 413) {
1455
+ return err(serviceError(
1456
+ ErrorCodes.KV_RESPONSE_TOO_LARGE,
1457
+ `KV value at key ${JSON.stringify(key)} exceeds the requested response limit`,
1458
+ "kv",
1459
+ { meta: { status: response.status, statusText: response.statusText } }
1460
+ ));
1461
+ }
1462
+ return err(serviceError(
1463
+ ErrorCodes.NETWORK_ERROR,
1464
+ `Failed to get key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
1465
+ "kv",
1466
+ { meta: { status: response.status, statusText: response.statusText } }
1467
+ ));
1468
+ }
1469
+ const data = await this.parseResponse(response, options?.raw, options?.binary);
1470
+ return ok({ data, headers: this.createResponseHeaders(response.headers) });
1471
+ }
1426
1472
  async batchGet(keys, options) {
1427
1473
  return this.withTelemetry(
1428
1474
  "batchGet",
@@ -1435,6 +1481,7 @@ var KVService = class extends BaseService {
1435
1481
  */
1436
1482
  async put(key, value, options) {
1437
1483
  return this.withTelemetry("put", key, async () => {
1484
+ if (this.readThroughReady !== null) await this.readThroughReady;
1438
1485
  if (!this.requireAuth()) {
1439
1486
  return err(authRequiredError("kv"));
1440
1487
  }
@@ -1449,63 +1496,15 @@ var KVService = class extends BaseService {
1449
1496
  const body = this.serializePutValue(value, options?.contentType);
1450
1497
  const request = this.createRequestSignal(options?.signal, options?.timeout);
1451
1498
  try {
1452
- const response = await this.invokeOperation(
1453
- path,
1454
- KVAction.PUT,
1455
- body,
1456
- request.signal,
1457
- {
1458
- ...options?.ifMatch === void 0 ? {} : { "if-match": options.ifMatch },
1459
- ...options?.ifNoneMatch === void 0 ? {} : { "if-none-match": options.ifNoneMatch }
1460
- }
1461
- );
1462
- if (response.status === 401 || response.status === 403) {
1463
- return this.authorizationFailure(
1464
- `Failed to put key ${JSON.stringify(key)}`,
1465
- response,
1466
- await this.readAuthorizationText(response),
1467
- [path],
1468
- KVAction.PUT
1469
- );
1470
- }
1471
- if (!response.ok) {
1472
- const errorText = await response.text();
1473
- if (response.status === 412) {
1474
- return err(serviceError(
1475
- ErrorCodes.KV_PRECONDITION_FAILED,
1476
- `KV precondition failed for key ${JSON.stringify(key)}`,
1477
- "kv",
1478
- { meta: { status: response.status, statusText: response.statusText } }
1479
- ));
1480
- }
1481
- if (response.status === 503 && (options?.ifMatch !== void 0 || options?.ifNoneMatch !== void 0)) {
1482
- return err(serviceError(
1483
- ErrorCodes.KV_CONFLICT,
1484
- `Concurrent KV update conflicted for key ${JSON.stringify(key)}`,
1485
- "kv",
1486
- { meta: { status: response.status, statusText: response.statusText } }
1487
- ));
1488
- }
1489
- const quotaError = this.handleQuotaErrorResponse(
1490
- response,
1491
- errorText,
1492
- key
1493
- );
1494
- if (quotaError) {
1495
- return quotaError;
1496
- }
1497
- return err(
1498
- serviceError(
1499
- ErrorCodes.KV_WRITE_FAILED,
1500
- `Failed to put key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
1501
- "kv",
1502
- { meta: { status: response.status, statusText: response.statusText } }
1503
- )
1504
- );
1505
- }
1506
- return ok({
1507
- data: void 0,
1508
- headers: this.createResponseHeaders(response.headers)
1499
+ const network = () => this.putToNetwork(key, path, body, options, request.signal);
1500
+ if (this.readThrough === null) return await network();
1501
+ return await this.readThrough.write({
1502
+ op: "put",
1503
+ space: this.context.session.spaceId,
1504
+ entries: [{ path, body }],
1505
+ signal: request.signal,
1506
+ conditional: options?.ifMatch !== void 0 || options?.ifNoneMatch !== void 0,
1507
+ network
1509
1508
  });
1510
1509
  } catch (error) {
1511
1510
  return err(wrapError("kv", error));
@@ -1514,11 +1513,64 @@ var KVService = class extends BaseService {
1514
1513
  }
1515
1514
  });
1516
1515
  }
1516
+ async putToNetwork(key, path, body, options, signal) {
1517
+ const response = await this.invokeOperation(
1518
+ path,
1519
+ KVAction.PUT,
1520
+ body,
1521
+ signal,
1522
+ {
1523
+ ...options?.ifMatch === void 0 ? {} : { "if-match": options.ifMatch },
1524
+ ...options?.ifNoneMatch === void 0 ? {} : { "if-none-match": options.ifNoneMatch }
1525
+ }
1526
+ );
1527
+ if (response.status === 401 || response.status === 403) {
1528
+ return this.authorizationFailure(
1529
+ `Failed to put key ${JSON.stringify(key)}`,
1530
+ response,
1531
+ await this.readAuthorizationText(response),
1532
+ [path],
1533
+ KVAction.PUT
1534
+ );
1535
+ }
1536
+ if (!response.ok) {
1537
+ const errorText = await response.text();
1538
+ if (response.status === 412) {
1539
+ return err(serviceError(
1540
+ ErrorCodes.KV_PRECONDITION_FAILED,
1541
+ `KV precondition failed for key ${JSON.stringify(key)}`,
1542
+ "kv",
1543
+ { meta: { status: response.status, statusText: response.statusText } }
1544
+ ));
1545
+ }
1546
+ if (response.status === 503 && (options?.ifMatch !== void 0 || options?.ifNoneMatch !== void 0)) {
1547
+ return err(serviceError(
1548
+ ErrorCodes.KV_CONFLICT,
1549
+ `Concurrent KV update conflicted for key ${JSON.stringify(key)}`,
1550
+ "kv",
1551
+ { meta: { status: response.status, statusText: response.statusText } }
1552
+ ));
1553
+ }
1554
+ const quotaError = this.handleQuotaErrorResponse(response, errorText, key);
1555
+ if (quotaError) return quotaError;
1556
+ return err(serviceError(
1557
+ ErrorCodes.KV_WRITE_FAILED,
1558
+ `Failed to put key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
1559
+ "kv",
1560
+ { meta: { status: response.status, statusText: response.statusText } }
1561
+ ));
1562
+ }
1563
+ return ok({
1564
+ data: void 0,
1565
+ headers: this.createResponseHeaders(response.headers)
1566
+ });
1567
+ }
1517
1568
  /**
1518
1569
  * Store multiple values in one TinyCloud KV invocation.
1519
1570
  */
1520
1571
  async batchPut(items, options) {
1521
1572
  return this.withTelemetry("batchPut", String(items.length), async () => {
1573
+ if (this.readThroughReady !== null) await this.readThroughReady;
1522
1574
  if (!this.requireAuth()) {
1523
1575
  return err(authRequiredError("kv"));
1524
1576
  }
@@ -1549,155 +1601,177 @@ var KVService = class extends BaseService {
1549
1601
  }
1550
1602
  seen.add(path);
1551
1603
  }
1552
- let requestMayHaveDispatched = false;
1553
1604
  const request = this.createRequestSignal(options?.signal, options?.timeout);
1554
1605
  try {
1555
- const body = new FormData();
1556
- for (let index = 0; index < items.length; index++) {
1557
- body.append(
1558
- encodeKvBatchPartName(paths[index]),
1559
- this.serializeBatchPutValue(items[index])
1560
- );
1561
- }
1562
- const headers = this.context.invokeAny(
1563
- session,
1564
- paths.map((path) => ({
1565
- spaceId: session.spaceId,
1566
- service: "kv",
1567
- path,
1568
- action: KVAction.PUT
1569
- }))
1606
+ const network = () => this.batchPutToNetwork(items, paths, request.signal);
1607
+ if (this.readThrough === null) return await network();
1608
+ const entries = items.map((item, index) => ({
1609
+ path: paths[index],
1610
+ body: this.serializeBatchPutValue(item)
1611
+ }));
1612
+ return await this.readThrough.write({
1613
+ op: "batchPut",
1614
+ space: session.spaceId,
1615
+ entries,
1616
+ signal: request.signal,
1617
+ network: () => this.batchPutToNetwork(items, paths, request.signal, entries)
1618
+ });
1619
+ } catch (error) {
1620
+ const wrapped = wrapError("kv", error);
1621
+ return err({ ...wrapped, meta: { ...wrapped.meta, requestMayHaveDispatched: false } });
1622
+ } finally {
1623
+ request.dispose();
1624
+ }
1625
+ });
1626
+ }
1627
+ async batchPutToNetwork(items, paths, signal, entries) {
1628
+ const session = this.context.session;
1629
+ let requestMayHaveDispatched = false;
1630
+ try {
1631
+ const body = new FormData();
1632
+ for (let index = 0; index < items.length; index++) {
1633
+ body.append(
1634
+ encodeKvBatchPartName(paths[index]),
1635
+ entries?.[index]?.body ?? this.serializeBatchPutValue(items[index])
1570
1636
  );
1571
- const url = `${this.host}/invoke`;
1572
- const init = {
1573
- method: "POST",
1574
- headers,
1575
- body,
1576
- signal: request.signal
1577
- };
1578
- const fetchFn = this.context.fetch;
1579
- requestMayHaveDispatched = true;
1580
- const response = await fetchFn(url, init);
1581
- if (!response.ok) {
1582
- let errorText;
1583
- try {
1584
- errorText = await response.text();
1585
- } catch (textError) {
1586
- const cause = textError instanceof Error ? textError : new Error(String(textError));
1587
- return err(
1588
- serviceError(
1589
- ErrorCodes.KV_WRITE_FAILED,
1590
- `Failed to batch put ${items.length} key(s): ${response.status} - <response body could not be read: ${cause.message}>`,
1591
- "kv",
1592
- { cause, meta: { status: response.status, statusText: response.statusText } }
1593
- )
1594
- );
1595
- }
1596
- if (response.status === 401 || response.status === 403) {
1597
- return this.authorizationFailure(
1598
- `Failed to batch put ${items.length} key(s)`,
1599
- response,
1600
- errorText,
1601
- paths,
1602
- KVAction.PUT
1603
- );
1604
- }
1605
- const quotaError = this.handleQuotaErrorResponse(
1606
- response,
1607
- errorText,
1608
- "batch"
1609
- );
1610
- if (quotaError) {
1611
- return quotaError;
1612
- }
1637
+ }
1638
+ const headers = this.context.invokeAny(
1639
+ session,
1640
+ paths.map((path) => ({
1641
+ spaceId: session.spaceId,
1642
+ service: "kv",
1643
+ path,
1644
+ action: KVAction.PUT
1645
+ }))
1646
+ );
1647
+ const url = `${this.host}/invoke`;
1648
+ const init = {
1649
+ method: "POST",
1650
+ headers,
1651
+ body,
1652
+ signal
1653
+ };
1654
+ const fetchFn = this.context.fetch;
1655
+ requestMayHaveDispatched = true;
1656
+ const response = await fetchFn(url, init);
1657
+ if (!response.ok) {
1658
+ let errorText;
1659
+ try {
1660
+ errorText = await response.text();
1661
+ } catch (textError) {
1662
+ const cause = textError instanceof Error ? textError : new Error(String(textError));
1613
1663
  return err(
1614
1664
  serviceError(
1615
1665
  ErrorCodes.KV_WRITE_FAILED,
1616
- `Failed to batch put ${items.length} key(s): ${response.status} - ${errorText}`,
1666
+ `Failed to batch put ${items.length} key(s): ${response.status} - <response body could not be read: ${cause.message}>`,
1617
1667
  "kv",
1618
- { meta: { status: response.status, statusText: response.statusText } }
1668
+ { cause, meta: { status: response.status, statusText: response.statusText } }
1619
1669
  )
1620
1670
  );
1621
1671
  }
1622
- let rawBody;
1623
- try {
1624
- rawBody = await response.json();
1625
- } catch (jsonError) {
1626
- const cause = jsonError instanceof Error ? jsonError : new Error(String(jsonError));
1627
- const timedOut = isRequestTimeoutError(cause);
1628
- return err(
1629
- serviceError(
1630
- timedOut ? ErrorCodes.TIMEOUT : ErrorCodes.NETWORK_ERROR,
1631
- timedOut ? `KV batchPut response body did not arrive in time: ${cause.message}` : `KV batchPut response was not valid JSON: ${cause.message}`,
1632
- "kv",
1633
- {
1634
- cause,
1635
- meta: {
1636
- ...timedOut ? { timeoutMs: cause.timeoutMs } : {},
1637
- requestMayHaveDispatched: true,
1638
- responseReceived: true,
1639
- status: response.status,
1640
- outcome: "batch-unconfirmed"
1641
- }
1642
- }
1643
- )
1672
+ if (response.status === 401 || response.status === 403) {
1673
+ return this.authorizationFailure(
1674
+ `Failed to batch put ${items.length} key(s)`,
1675
+ response,
1676
+ errorText,
1677
+ paths,
1678
+ KVAction.PUT
1644
1679
  );
1645
1680
  }
1646
- const batchResponse = this.normalizeBatchPutResponse(rawBody);
1647
- if (!batchResponse) {
1648
- return err(
1649
- serviceError(
1650
- ErrorCodes.NETWORK_ERROR,
1651
- "KV batchPut response did not include matching written keys and count",
1652
- "kv",
1653
- {
1654
- meta: {
1655
- requestMayHaveDispatched: true,
1656
- responseReceived: true,
1657
- status: response.status,
1658
- outcome: "batch-unconfirmed"
1659
- }
1660
- }
1661
- )
1662
- );
1681
+ const quotaError = this.handleQuotaErrorResponse(
1682
+ response,
1683
+ errorText,
1684
+ "batch"
1685
+ );
1686
+ if (quotaError) {
1687
+ return quotaError;
1663
1688
  }
1664
- const requestedPaths = new Set(paths);
1665
- const writtenPaths = new Set(batchResponse.written);
1666
- const matchesRequest = batchResponse.count === paths.length && batchResponse.written.length === paths.length && writtenPaths.size === paths.length && batchResponse.written.every((key) => requestedPaths.has(key));
1667
- if (!matchesRequest) {
1668
- return err(
1669
- serviceError(
1670
- ErrorCodes.NETWORK_ERROR,
1671
- `KV batchPut response did not confirm all ${paths.length} requested key(s) were written`,
1672
- "kv",
1673
- {
1674
- meta: {
1675
- requestMayHaveDispatched: true,
1676
- responseReceived: true,
1677
- status: response.status,
1678
- outcome: "batch-unconfirmed"
1679
- }
1689
+ return err(
1690
+ serviceError(
1691
+ ErrorCodes.KV_WRITE_FAILED,
1692
+ `Failed to batch put ${items.length} key(s): ${response.status} - ${errorText}`,
1693
+ "kv",
1694
+ { meta: { status: response.status, statusText: response.statusText } }
1695
+ )
1696
+ );
1697
+ }
1698
+ let rawBody;
1699
+ try {
1700
+ rawBody = await response.json();
1701
+ } catch (jsonError) {
1702
+ const cause = jsonError instanceof Error ? jsonError : new Error(String(jsonError));
1703
+ const timedOut = isRequestTimeoutError(cause);
1704
+ return err(
1705
+ serviceError(
1706
+ timedOut ? ErrorCodes.TIMEOUT : ErrorCodes.NETWORK_ERROR,
1707
+ timedOut ? `KV batchPut response body did not arrive in time: ${cause.message}` : `KV batchPut response was not valid JSON: ${cause.message}`,
1708
+ "kv",
1709
+ {
1710
+ cause,
1711
+ meta: {
1712
+ ...timedOut ? { timeoutMs: cause.timeoutMs } : {},
1713
+ requestMayHaveDispatched: true,
1714
+ responseReceived: true,
1715
+ status: response.status,
1716
+ outcome: "batch-unconfirmed"
1680
1717
  }
1681
- )
1682
- );
1683
- }
1684
- return ok(batchResponse);
1685
- } catch (error) {
1686
- const wrapped = wrapError("kv", error);
1687
- return err({
1688
- ...wrapped,
1689
- meta: { ...wrapped.meta, requestMayHaveDispatched }
1690
- });
1691
- } finally {
1692
- request.dispose();
1718
+ }
1719
+ )
1720
+ );
1693
1721
  }
1694
- });
1722
+ const batchResponse = this.normalizeBatchPutResponse(rawBody);
1723
+ if (!batchResponse) {
1724
+ return err(
1725
+ serviceError(
1726
+ ErrorCodes.NETWORK_ERROR,
1727
+ "KV batchPut response did not include matching written keys and count",
1728
+ "kv",
1729
+ {
1730
+ meta: {
1731
+ requestMayHaveDispatched: true,
1732
+ responseReceived: true,
1733
+ status: response.status,
1734
+ outcome: "batch-unconfirmed"
1735
+ }
1736
+ }
1737
+ )
1738
+ );
1739
+ }
1740
+ const requestedPaths = new Set(paths);
1741
+ const writtenPaths = new Set(batchResponse.written);
1742
+ const matchesRequest = batchResponse.count === paths.length && batchResponse.written.length === paths.length && writtenPaths.size === paths.length && batchResponse.written.every((key) => requestedPaths.has(key));
1743
+ if (!matchesRequest) {
1744
+ return err(
1745
+ serviceError(
1746
+ ErrorCodes.NETWORK_ERROR,
1747
+ `KV batchPut response did not confirm all ${paths.length} requested key(s) were written`,
1748
+ "kv",
1749
+ {
1750
+ meta: {
1751
+ requestMayHaveDispatched: true,
1752
+ responseReceived: true,
1753
+ status: response.status,
1754
+ outcome: "batch-unconfirmed"
1755
+ }
1756
+ }
1757
+ )
1758
+ );
1759
+ }
1760
+ return ok(batchResponse);
1761
+ } catch (error) {
1762
+ const wrapped = wrapError("kv", error);
1763
+ return err({
1764
+ ...wrapped,
1765
+ meta: { ...wrapped.meta, requestMayHaveDispatched }
1766
+ });
1767
+ }
1695
1768
  }
1696
1769
  /**
1697
1770
  * List keys with optional prefix filtering.
1698
1771
  */
1699
1772
  async list(options) {
1700
1773
  return this.withTelemetry("list", options?.prefix, async () => {
1774
+ if (this.readThroughReady !== null) await this.readThroughReady;
1701
1775
  if (!this.requireAuth()) {
1702
1776
  return err(authRequiredError("kv"));
1703
1777
  }
@@ -1714,49 +1788,44 @@ var KVService = class extends BaseService {
1714
1788
  }
1715
1789
  const request = this.createRequestSignal(options?.signal, options?.timeout);
1716
1790
  try {
1717
- const response = await this.invokeOperation(
1718
- listPath,
1719
- KVAction.LIST,
1720
- void 0,
1721
- request.signal,
1722
- options?.limit === void 0 && options?.cursor === void 0 ? void 0 : {
1723
- ...options?.limit === void 0 ? {} : { "x-tinycloud-limit": String(options.limit) },
1724
- ...options?.cursor === void 0 ? {} : { "x-tinycloud-cursor": options.cursor }
1725
- }
1726
- );
1727
- if (!response.ok) {
1728
- if (response.status === 401 || response.status === 403) {
1729
- return this.authorizationFailure(
1730
- "Failed to list keys",
1731
- response,
1732
- await this.readAuthorizationText(response),
1733
- [listPath],
1734
- KVAction.LIST
1735
- );
1791
+ const network = () => this.listFromNetwork(listPath, options, request.signal);
1792
+ let page;
1793
+ if (options?.source === "network") {
1794
+ const observer = this.readThrough;
1795
+ if (observer === null) {
1796
+ page = await network();
1797
+ } else {
1798
+ let outcome = "error";
1799
+ const startedAt = Date.now();
1800
+ try {
1801
+ page = await network();
1802
+ outcome = page.ok ? "found" : "error";
1803
+ } finally {
1804
+ try {
1805
+ observer.observeNetworkRequested({
1806
+ op: "list",
1807
+ space: this.context.session.spaceId,
1808
+ path: listPath,
1809
+ reason: "NETWORK_REQUESTED",
1810
+ outcome,
1811
+ latencyMs: Date.now() - startedAt
1812
+ });
1813
+ } catch {
1814
+ }
1815
+ }
1736
1816
  }
1737
- const errorText = await response.text();
1738
- return err(
1739
- serviceError(
1740
- ErrorCodes.NETWORK_ERROR,
1741
- `Failed to list keys: ${response.status} - ${errorText}`,
1742
- "kv",
1743
- { meta: { status: response.status, statusText: response.statusText } }
1744
- )
1745
- );
1746
- }
1747
- let keys = await this.parseResponse(response, options?.raw);
1748
- keys = keys ?? [];
1749
- if (options?.removePrefix && listPath) {
1750
- const prefixWithSlash = listPath.endsWith("/") ? listPath : `${listPath}/`;
1751
- keys = keys.map(
1752
- (key) => key.startsWith(prefixWithSlash) ? key.slice(prefixWithSlash.length) : key
1753
- );
1817
+ } else if (this.readThrough === null) {
1818
+ page = await network();
1819
+ } else {
1820
+ page = await this.readThrough.list({
1821
+ space: this.context.session.spaceId,
1822
+ listPath,
1823
+ options,
1824
+ signal: request.signal,
1825
+ network
1826
+ });
1754
1827
  }
1755
- return ok({
1756
- keys,
1757
- ...response.headers.get("x-tinycloud-truncated") === null ? {} : { truncated: response.headers.get("x-tinycloud-truncated") === "true" },
1758
- ...response.headers.get("x-tinycloud-next-cursor") === null ? {} : { nextCursor: response.headers.get("x-tinycloud-next-cursor") ?? void 0 }
1759
- });
1828
+ return page.ok ? this.finishList(page.data, listPath, options?.removePrefix) : page;
1760
1829
  } catch (error) {
1761
1830
  return err(wrapError("kv", error));
1762
1831
  } finally {
@@ -1764,6 +1833,56 @@ var KVService = class extends BaseService {
1764
1833
  }
1765
1834
  });
1766
1835
  }
1836
+ async listFromNetwork(listPath, options, signal) {
1837
+ const response = await this.invokeOperation(
1838
+ listPath,
1839
+ KVAction.LIST,
1840
+ void 0,
1841
+ signal,
1842
+ options?.limit === void 0 && options?.cursor === void 0 ? void 0 : {
1843
+ ...options?.limit === void 0 ? {} : { "x-tinycloud-limit": String(options.limit) },
1844
+ ...options?.cursor === void 0 ? {} : { "x-tinycloud-cursor": options.cursor }
1845
+ }
1846
+ );
1847
+ if (!response.ok) {
1848
+ if (response.status === 401 || response.status === 403) {
1849
+ return this.authorizationFailure(
1850
+ "Failed to list keys",
1851
+ response,
1852
+ await this.readAuthorizationText(response),
1853
+ [listPath],
1854
+ KVAction.LIST
1855
+ );
1856
+ }
1857
+ const errorText = await response.text();
1858
+ return err(serviceError(
1859
+ ErrorCodes.NETWORK_ERROR,
1860
+ `Failed to list keys: ${response.status} - ${errorText}`,
1861
+ "kv",
1862
+ { meta: { status: response.status, statusText: response.statusText } }
1863
+ ));
1864
+ }
1865
+ const keys = await this.parseResponse(response, options?.raw) ?? [];
1866
+ return ok({
1867
+ keys,
1868
+ ...response.headers.get("x-tinycloud-truncated") === null ? {} : { truncated: response.headers.get("x-tinycloud-truncated") === "true" },
1869
+ ...response.headers.get("x-tinycloud-next-cursor") === null ? {} : { nextCursor: response.headers.get("x-tinycloud-next-cursor") ?? void 0 }
1870
+ });
1871
+ }
1872
+ finishList(page, listPath, removePrefix) {
1873
+ let keys = page.keys;
1874
+ if (removePrefix && listPath) {
1875
+ const prefixWithSlash = listPath.endsWith("/") ? listPath : `${listPath}/`;
1876
+ keys = keys.map(
1877
+ (key) => key.startsWith(prefixWithSlash) ? key.slice(prefixWithSlash.length) : key
1878
+ );
1879
+ }
1880
+ return ok({
1881
+ keys,
1882
+ ...page.truncated === void 0 ? {} : { truncated: page.truncated },
1883
+ ...page.nextCursor === void 0 ? {} : { nextCursor: page.nextCursor }
1884
+ });
1885
+ }
1767
1886
  /**
1768
1887
  * Read one page of the `tinycloud.kv/sync` change feed for a prefix.
1769
1888
  */
@@ -1932,61 +2051,22 @@ var KVService = class extends BaseService {
1932
2051
  */
1933
2052
  async delete(key, options) {
1934
2053
  return this.withTelemetry("delete", key, async () => {
2054
+ if (this.readThroughReady !== null) await this.readThroughReady;
1935
2055
  if (!this.requireAuth()) {
1936
2056
  return err(authRequiredError("kv"));
1937
2057
  }
1938
2058
  const path = this.getFullPath(key, options?.prefix);
1939
2059
  const request = this.createRequestSignal(options?.signal, options?.timeout);
1940
2060
  try {
1941
- const response = await this.invokeOperation(
1942
- path,
1943
- KVAction.DELETE,
1944
- void 0,
1945
- request.signal,
1946
- options?.ifMatch === void 0 ? void 0 : { "if-match": options.ifMatch }
1947
- );
1948
- if (!response.ok) {
1949
- if (response.status === 401 || response.status === 403) {
1950
- return this.authorizationFailure(
1951
- `Failed to delete key ${JSON.stringify(key)}`,
1952
- response,
1953
- await this.readAuthorizationText(response),
1954
- [path],
1955
- KVAction.DELETE
1956
- );
1957
- }
1958
- if (response.status === 404) {
1959
- return await this.classifyNotFound(response, key);
1960
- }
1961
- const errorText = await response.text();
1962
- if (response.status === 412) {
1963
- return err(serviceError(
1964
- ErrorCodes.KV_PRECONDITION_FAILED,
1965
- `KV precondition failed for key ${JSON.stringify(key)}`,
1966
- "kv",
1967
- { meta: { status: response.status, statusText: response.statusText } }
1968
- ));
1969
- }
1970
- if (response.status === 503 && options?.ifMatch !== void 0) {
1971
- return err(serviceError(
1972
- ErrorCodes.KV_CONFLICT,
1973
- `Concurrent KV delete conflicted for key ${JSON.stringify(key)}`,
1974
- "kv",
1975
- { meta: { status: response.status, statusText: response.statusText } }
1976
- ));
1977
- }
1978
- return err(
1979
- serviceError(
1980
- ErrorCodes.NETWORK_ERROR,
1981
- `Failed to delete key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
1982
- "kv",
1983
- { meta: { status: response.status, statusText: response.statusText } }
1984
- )
1985
- );
1986
- }
1987
- return ok({
1988
- data: void 0,
1989
- headers: this.createResponseHeaders(response.headers)
2061
+ const network = () => this.deleteFromNetwork(key, path, options, request.signal);
2062
+ if (this.readThrough === null) return await network();
2063
+ return await this.readThrough.write({
2064
+ op: "delete",
2065
+ space: this.context.session.spaceId,
2066
+ entries: [{ path }],
2067
+ signal: request.signal,
2068
+ conditional: options?.ifMatch !== void 0,
2069
+ network
1990
2070
  });
1991
2071
  } catch (error) {
1992
2072
  return err(wrapError("kv", error));
@@ -1995,6 +2075,54 @@ var KVService = class extends BaseService {
1995
2075
  }
1996
2076
  });
1997
2077
  }
2078
+ async deleteFromNetwork(key, path, options, signal) {
2079
+ const response = await this.invokeOperation(
2080
+ path,
2081
+ KVAction.DELETE,
2082
+ void 0,
2083
+ signal,
2084
+ options?.ifMatch === void 0 ? void 0 : { "if-match": options.ifMatch }
2085
+ );
2086
+ if (!response.ok) {
2087
+ if (response.status === 401 || response.status === 403) {
2088
+ return this.authorizationFailure(
2089
+ `Failed to delete key ${JSON.stringify(key)}`,
2090
+ response,
2091
+ await this.readAuthorizationText(response),
2092
+ [path],
2093
+ KVAction.DELETE
2094
+ );
2095
+ }
2096
+ if (response.status === 404) return this.classifyNotFound(response, key);
2097
+ const errorText = await response.text();
2098
+ if (response.status === 412) {
2099
+ return err(serviceError(
2100
+ ErrorCodes.KV_PRECONDITION_FAILED,
2101
+ `KV precondition failed for key ${JSON.stringify(key)}`,
2102
+ "kv",
2103
+ { meta: { status: response.status, statusText: response.statusText } }
2104
+ ));
2105
+ }
2106
+ if (response.status === 503 && options?.ifMatch !== void 0) {
2107
+ return err(serviceError(
2108
+ ErrorCodes.KV_CONFLICT,
2109
+ `Concurrent KV delete conflicted for key ${JSON.stringify(key)}`,
2110
+ "kv",
2111
+ { meta: { status: response.status, statusText: response.statusText } }
2112
+ ));
2113
+ }
2114
+ return err(serviceError(
2115
+ ErrorCodes.NETWORK_ERROR,
2116
+ `Failed to delete key ${JSON.stringify(key)}: ${response.status} - ${errorText}`,
2117
+ "kv",
2118
+ { meta: { status: response.status, statusText: response.statusText } }
2119
+ ));
2120
+ }
2121
+ return ok({
2122
+ data: void 0,
2123
+ headers: this.createResponseHeaders(response.headers)
2124
+ });
2125
+ }
1998
2126
  /**
1999
2127
  * Get metadata for a key without retrieving the value.
2000
2128
  */
@@ -2161,10 +2289,73 @@ var KVService = class extends BaseService {
2161
2289
  * Service identifier for registration.
2162
2290
  */
2163
2291
  KVService.serviceName = "kv";
2292
+
2293
+ // src/kv/replication/constants.ts
2294
+ 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.";
2295
+
2296
+ // src/kv/replication/identity.ts
2297
+ var SPACE_ID = /^(tinycloud:pkh:eip155:\d+:)([^:]+)(:.+)$/;
2298
+ var PRINCIPAL_DID = /^(did:pkh:eip155:\d+:)([^:]+)$/;
2299
+ function canonicalReplicationIdentity(raw) {
2300
+ if (!raw || typeof raw.host !== "string" || typeof raw.space !== "string" || typeof raw.principal !== "string") {
2301
+ throw new TypeError(
2302
+ "Replication identity host, space, and principal must be strings"
2303
+ );
2304
+ }
2305
+ const authority = /^https?:\/\/([^/?#]*)/i.exec(raw.host)?.[1];
2306
+ let url;
2307
+ try {
2308
+ url = new URL(raw.host);
2309
+ } catch {
2310
+ throw new TypeError(
2311
+ "Replication identity host must be an absolute HTTP(S) URL"
2312
+ );
2313
+ }
2314
+ if (!authority || authority.includes("@") || raw.host.includes("?") || raw.host.includes("#") || url.username || url.password) {
2315
+ throw new TypeError(
2316
+ "Replication identity host must be HTTP(S) without credentials, query, or fragment"
2317
+ );
2318
+ }
2319
+ const space = SPACE_ID.exec(raw.space);
2320
+ const principal = PRINCIPAL_DID.exec(raw.principal);
2321
+ if (!space || !principal) {
2322
+ throw new TypeError(
2323
+ "Replication identity requires a full TinyCloud space id and a pkh principal DID"
2324
+ );
2325
+ }
2326
+ return {
2327
+ host: `${url.origin}${url.pathname.replace(/\/+$/, "")}`,
2328
+ space: `${space[1]}${space[2].toLowerCase()}${space[3]}`,
2329
+ principal: `${principal[1]}${principal[2].toLowerCase()}`
2330
+ };
2331
+ }
2332
+ function replicationIdentityKey(id) {
2333
+ const canonical = canonicalReplicationIdentity(id);
2334
+ return `tc-replication/v1
2335
+ ${canonical.host}
2336
+ ${canonical.space}
2337
+ ${canonical.principal}`;
2338
+ }
2339
+
2340
+ // src/kv/replication/scope.ts
2341
+ function kvPrefixCovers(prefix, key) {
2342
+ if (prefix === "") return true;
2343
+ if (prefix.endsWith("/")) return key.startsWith(prefix);
2344
+ return key === prefix || key.startsWith(`${prefix}/`);
2345
+ }
2346
+ function requiresSecretsOptIn(space, prefix) {
2347
+ if (space === "secrets" || space.endsWith(":secrets")) return true;
2348
+ return prefix.split("/", 1)[0] === "vault";
2349
+ }
2164
2350
  export {
2351
+ CLEAR_PENDING_WARNING,
2165
2352
  DEFAULT_SIGNED_READ_URL_EXPIRY_MS,
2166
2353
  KVAction,
2167
2354
  KVService,
2168
- PrefixedKVService
2355
+ PrefixedKVService,
2356
+ canonicalReplicationIdentity,
2357
+ kvPrefixCovers,
2358
+ replicationIdentityKey,
2359
+ requiresSecretsOptIn
2169
2360
  };
2170
2361
  //# sourceMappingURL=index.js.map