@tinycloud/sdk-services 3.2.6-beta.4 → 3.3.0-beta.7

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-1Ug6y7DC.d.cts → BaseService-Cc6Ueicm.d.cts} +1 -1
  2. package/dist/{BaseService-Bn07Vudy.d.ts → BaseService-DOdXd7sr.d.ts} +1 -1
  3. package/dist/encryption/index.cjs +8 -0
  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 +8 -0
  8. package/dist/encryption/index.js.map +1 -1
  9. package/dist/index.cjs +94 -57
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +29 -6
  12. package/dist/index.d.ts +29 -6
  13. package/dist/index.js +93 -57
  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 +66 -21
  18. package/dist/kv/index.cjs.map +1 -1
  19. package/dist/kv/index.d.cts +4 -4
  20. package/dist/kv/index.d.ts +4 -4
  21. package/dist/kv/index.js +66 -21
  22. package/dist/kv/index.js.map +1 -1
  23. package/dist/kv/replication/grantPolicy.cjs +82 -0
  24. package/dist/kv/replication/grantPolicy.cjs.map +1 -0
  25. package/dist/kv/replication/grantPolicy.d.cts +19 -0
  26. package/dist/kv/replication/grantPolicy.d.ts +19 -0
  27. package/dist/kv/replication/grantPolicy.js +50 -0
  28. package/dist/kv/replication/grantPolicy.js.map +1 -0
  29. package/dist/kv/replication/index.cjs +571 -128
  30. package/dist/kv/replication/index.cjs.map +1 -1
  31. package/dist/kv/replication/index.d.cts +3 -3
  32. package/dist/kv/replication/index.d.ts +3 -3
  33. package/dist/kv/replication/index.js +571 -128
  34. package/dist/kv/replication/index.js.map +1 -1
  35. package/dist/{scope-BnyVPZ5E.d.ts → scope-B-m6cKH-.d.ts} +310 -7
  36. package/dist/{scope-CQ5kmHlt.d.cts → scope-B7oeKdLM.d.cts} +310 -7
  37. package/dist/sql/index.cjs +70 -36
  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 +70 -36
  42. package/dist/sql/index.js.map +1 -1
  43. package/dist/{types-gmdUiorz.d.cts → types-BjH0ZtGy.d.cts} +6 -0
  44. package/dist/{types-gmdUiorz.d.ts → types-BjH0ZtGy.d.ts} +6 -0
  45. package/package.json +16 -2
@@ -1,4 +1,4 @@
1
- import { R as Result, F as FetchFunction } from './types-gmdUiorz.cjs';
1
+ import { R as Result, F as FetchFunction } from './types-BjH0ZtGy.cjs';
2
2
  import { REPLICA_KV_STORES } from './kv/replication/contractConstants.cjs';
3
3
  import { ReplicaConsentAnchor, ReplicaGrantPolicy } from './kv/replication/grantPolicyTypes.cjs';
4
4
 
@@ -526,7 +526,7 @@ interface ReplicaAccountGate {
526
526
  /** Active account's principal hash; null after sign-out. */
527
527
  principalHash: string | null;
528
528
  lifecycleEpoch: number;
529
- /** ms since epoch of the last deny (browser: preserved across database recreation). */
529
+ /** ms since epoch of the last committed deny (browser: preserved across database recreation). */
530
530
  deniedAt: number | null;
531
531
  /** Browser: finite ms, fixed from verified fresh consent at establish; mandatory while active. */
532
532
  consentDeadline?: number | null;
@@ -665,8 +665,9 @@ type ReplicaDenyOutcome =
665
665
  /**
666
666
  * Gate moved, intents recorded, first-install anchors cleared and pending generations advanced
667
667
  * in one commit. Browser: owner device deleted in that same IDB transaction. Node: the gate
668
- * file cannot commit atomically with the device file, so the device is durably deleted by its
669
- * identity's pending-intent erase before that intent clears (idempotent replay).
668
+ * file cannot commit atomically with the device file, so an ordinary denial deletes it
669
+ * through the identity's pending-intent erase before intent clear. Deferred node sign-out
670
+ * holds that intent and the device file until validated resume (CONTRACT.md §2).
670
671
  */
671
672
  {
672
673
  status: "denied";
@@ -691,6 +692,15 @@ type ReplicaDenyOutcome =
691
692
  status: "unguarded";
692
693
  code: string;
693
694
  };
695
+ /** A deferred sign-out denial: a committed `denied` always carries its resume token. */
696
+ type ProfileReplicaDeferredDenyOutcome = {
697
+ status: "denied";
698
+ lifecycleEpoch: number;
699
+ intents: number;
700
+ resumeToken: ProfileReplicaDeferredToken;
701
+ } | Exclude<ReplicaDenyOutcome, {
702
+ status: "denied";
703
+ }>;
694
704
  interface ReplicaEraseReport {
695
705
  erased: string[];
696
706
  failed: Array<{
@@ -699,6 +709,11 @@ interface ReplicaEraseReport {
699
709
  }>;
700
710
  /** Intents still outstanding after this pass. */
701
711
  remaining: number;
712
+ /**
713
+ * Node/CLI: a durable deferred denial held this pass, so nothing was erased and
714
+ * `remaining` (possibly 0) is not completion; check `deferredErase()`.
715
+ */
716
+ deferred?: true;
702
717
  /** Browser startup discovered elapsed consent and durably crypto-shredded the account/gate keys. */
703
718
  consentExpired?: boolean;
704
719
  }
@@ -781,6 +796,8 @@ interface ReplicaLifecycleGate {
781
796
  * if expired, denies and crypto-shreds keys before any replica access; it also retries a
782
797
  * pending `deleteDatabase` marker and erases intents. Failures remain gated and visible via
783
798
  * `pendingPurge()` / `purgePending`; a browser closed at expiry catches it on its next load.
799
+ * Node/CLI recovery leaves durably deferred revoke intents and physical files untouched,
800
+ * reporting them as remaining until `ProfileReplicaPurge.resumeDeferredErase`.
784
801
  */
785
802
  recover(o: {
786
803
  signal: AbortSignal;
@@ -830,6 +847,12 @@ interface ReplicaLifecycleGate {
830
847
  * return null when its gate has no persisted consent deadline.
831
848
  */
832
849
  consentDeadline(stamp: ReplicaSessionStamp): number | null;
850
+ /**
851
+ * Browser-only post-commit consent expiry observer. Fires only after deny/delete
852
+ * destroys the origin account keys; a subscriber failure cannot undo denial.
853
+ * Controllers register after binding so events use their opened opaque replica IDs.
854
+ */
855
+ subscribeConsentExpired?(listener: (stamp: ReplicaSessionStamp) => void): () => void;
833
856
  /**
834
857
  * Synchronous main-thread delivery check before replica-served values/list keys/event fields,
835
858
  * and before open, installGrant and sync. Browser checks marker, origin deny tombstone,
@@ -853,6 +876,275 @@ interface ReplicaLifecycleGate {
853
876
  /** Outstanding intents; non-empty means status `purgePending: true`. */
854
877
  pendingPurge(): Promise<ReplicaPurgeIntent[]>;
855
878
  }
879
+ /** Node/CLI profile-local fencing token, not a secret capability; the profile lock remains authoritative. */
880
+ interface ProfileReplicaDeferredToken {
881
+ deniedEpoch: number;
882
+ principalHash: string;
883
+ }
884
+ /** Profile-relative, JSON-safe identity of a path listed before destructive logout. */
885
+ interface ProfileReplicaUnknownEntry {
886
+ path: string;
887
+ kind: "file" | "directory" | "symlink";
888
+ dev: string;
889
+ ino: string;
890
+ }
891
+ /** Profile-relative directory identity; dev/ino are decimal strings for durable JSON. */
892
+ interface ProfileReplicaRootIdentity {
893
+ path: string;
894
+ dev: string;
895
+ ino: string;
896
+ }
897
+ /** Exact nonsecret path identity displayed with an inventory confirmation token. */
898
+ interface ProfileReplicaApprovalEntry extends ProfileReplicaRootIdentity {
899
+ kind: ProfileReplicaUnknownEntry["kind"] | "missing";
900
+ }
901
+ /** Read-only profile-locked inventory, including legacy mirrors and flag-owned roots. */
902
+ interface ProfileReplicaPreflight {
903
+ knownLegacyReplicas: number;
904
+ unknownLegacyPaths: string[];
905
+ unknownPaths: string[];
906
+ unknownEntries: ProfileReplicaUnknownEntry[];
907
+ knownRoots: ProfileReplicaRootIdentity[];
908
+ knownEntries: ProfileReplicaUnknownEntry[];
909
+ missingDatabases: string[];
910
+ }
911
+ /**
912
+ * CLI-only durable crash fence. `principalDigest` is a domain-separated
913
+ * SHA-256 owner binding, not a secret or a substitute for the profile lock;
914
+ * v2/v3 never fabricate a scoped gate principal hash. A v1 `lifecycleEpoch`
915
+ * is the pre-denial epoch; `deniedEpoch` is the epoch the v1 denial actually
916
+ * committed, bound once after that denial and compared for equality from then
917
+ * on (CONTRACT.md §2). Remote-pending and remote-abandoned are mutually
918
+ * exclusive durable states.
919
+ */
920
+ type ProfileReplicaLogoutPending = ({
921
+ v: 1;
922
+ incarnation: string;
923
+ lifecycleEpoch: number;
924
+ principalHash: string;
925
+ deniedEpoch?: number;
926
+ } | {
927
+ v: 2;
928
+ scope: "orphan_profile";
929
+ nonce: string;
930
+ } | {
931
+ v: 3;
932
+ scope: "inactive_gate";
933
+ incarnation: string;
934
+ lifecycleEpoch: number;
935
+ }) & ProfileReplicaRemoteLogoutState & {
936
+ principalDigest: string;
937
+ /** Random durable 16-byte challenge salt as 32 lowercase hex characters. */
938
+ approvalSalt: string;
939
+ discardLocalChanges?: true;
940
+ approvedUnknown: ProfileReplicaUnknownEntry[];
941
+ knownRoots: ProfileReplicaRootIdentity[];
942
+ knownEntries: ProfileReplicaUnknownEntry[];
943
+ missingDatabases: string[];
944
+ };
945
+ /** A logout marker is remote-pending, remote-abandoned or neither; never both. */
946
+ type ProfileReplicaRemoteLogoutState = {
947
+ remotePending: true;
948
+ remoteAbandoned?: never;
949
+ } | {
950
+ remoteAbandoned: true;
951
+ remotePending?: never;
952
+ } | {
953
+ remotePending?: never;
954
+ remoteAbandoned?: never;
955
+ };
956
+ /**
957
+ * Node/CLI-only lifecycle facade for a profile's replica root. The node-sdk exports
958
+ * `createProfileReplicaPurge({ profileDir, guard })`; `guard` is the caller's
959
+ * mandatory profile lock and MUST be reentrant within one async call chain: the
960
+ * CLI calls the facade while already holding its (reentrant) profile lock. This
961
+ * does not widen the browser `ReplicaLifecycleGate`.
962
+ *
963
+ * While a durable deferred denial is active, `recover` and `erasePending`
964
+ * leave intents and physical files untouched; `pendingPurge` lists the intents.
965
+ * A complete enumeration may have zero intents, so `remaining: 0` does not
966
+ * prove completion: `deferredErase()` is the authoritative marker check, and
967
+ * `LocalReplicaStatus.purgePending` / `ReplicaInspectedStatus.purgePending`
968
+ * are true while the deferred marker or any intent remains. All guarded reads
969
+ * and new establishment remain refused. Deferred intents leave that state only
970
+ * through a validated resume (CONTRACT.md §2); there is no account-switch or
971
+ * consent-expiry override. The facade never denies or erases a gate,
972
+ * partition, pending state or device record whose active principal is not
973
+ * the caller's ("Logout by another account", CONTRACT.md §2).
974
+ */
975
+ interface ProfileReplicaPurge extends Pick<ReplicaLifecycleGate, "recover" | "erasePending" | "pendingPurge"> {
976
+ /**
977
+ * `deferErase: true` is valid only for `reason: "sign_out"`; other reasons throw
978
+ * TypeError before any mutation. A successful deferred `denied` outcome MUST carry
979
+ * `resumeToken`; stale or unguarded outcomes do not authorize a retryable revoke.
980
+ * Step 1 atomically denies, records every opaque partition/pending intent and
981
+ * pending generation, and persists the deferred marker. Physical files remain
982
+ * until `resumeDeferredErase`, even across process restart.
983
+ */
984
+ deny(input: ReplicaDenyInput & {
985
+ reason: "sign_out";
986
+ }, options: {
987
+ deferErase: true;
988
+ }): Promise<ProfileReplicaDeferredDenyOutcome>;
989
+ deny(input: ReplicaDenyInput, options?: {
990
+ deferErase?: false;
991
+ }): Promise<ReplicaDenyOutcome>;
992
+ /** Returns the persisted active token across restart, even with zero intents; null only after the marker clears. */
993
+ deferredErase(): Promise<ProfileReplicaDeferredToken | null>;
994
+ /**
995
+ * Under the profile lock, require the exact denied epoch and principal hash and a
996
+ * null gate principal; otherwise throw `REPLICA_GATED`/`stamp_stale` without erasure.
997
+ * Clear the deferred marker durably before idempotent erase. A crash after clearing
998
+ * is completed by ordinary recovery. Repeating the same token is safe only while
999
+ * the same denied epoch/hash persists; a successor establishment fences it.
1000
+ */
1001
+ resumeDeferredErase(token: ProfileReplicaDeferredToken, options: {
1002
+ signal: AbortSignal;
1003
+ }): Promise<ReplicaEraseReport>;
1004
+ /**
1005
+ * Read-only profile-locked inventory BEFORE marker, denial or remote call.
1006
+ * Report exact profile-relative unknown paths, kinds and dev/ino plus all
1007
+ * known roots/entries. Unknown/indeterminate data refuses unless explicit
1008
+ * discard consent was supplied; the CLI displays the inventory before acting.
1009
+ * A prior inventory is not a wildcard authorization for later new paths.
1010
+ */
1011
+ preflightLogout(input: {
1012
+ principal: string;
1013
+ discardLocalChanges?: boolean;
1014
+ }): Promise<ProfileReplicaPreflight>;
1015
+ /**
1016
+ * Re-inspect and compare the complete approved inventory under the profile
1017
+ * lock, then atomically fsync a scoped v1, orphan v2 or inactive v3 marker.
1018
+ * `approval` is required: the CLI passes its just-displayed preflight even
1019
+ * when it contains no unknown paths. With `remotePending`, the marker fences
1020
+ * replica reads but forbids credential cleanup or erasure until
1021
+ * `completeRemoteLogout` after all exact-CID revokes. Only approved
1022
+ * path/type/dev/ino identities and the explicit discard bit survive; changed
1023
+ * paths refuse pre-action. V1 active remote logout additionally requires a
1024
+ * committed deferred denial/token before online revoke; v2 orphan and v3
1025
+ * inactive markers instead use their version-specific gate-absence/epoch
1026
+ * fences.
1027
+ *
1028
+ * An existing marker is never rewritten here: the same owner, fence,
1029
+ * `remotePending` state and approved inventory return it unchanged; anything
1030
+ * else refuses without mutation. When the gate's active principal is the
1031
+ * caller's, it always yields a v1 marker, even with no replica roots or
1032
+ * intents; another account's active gate yields null and is never denied.
1033
+ * Null otherwise means there is no active principal and no replica-owned
1034
+ * work, not completed logout.
1035
+ *
1036
+ * A missing flag pending index, unrecognized child, nonempty pending state
1037
+ * or corrupt gate requires explicit discard. The CLI still fsyncs
1038
+ * authSignedOut then unlinks and fsyncs both credentials. CLI-bound reads
1039
+ * and ordinary network restore refuse any marker; an explicit remote-revoke
1040
+ * retry may use the retained key/session while `remotePending` without
1041
+ * delivering replica data.
1042
+ */
1043
+ prepareLogoutPending(input: {
1044
+ principal: string;
1045
+ approval: ProfileReplicaPreflight;
1046
+ discardLocalChanges?: boolean;
1047
+ remotePending?: true;
1048
+ }): Promise<ProfileReplicaLogoutPending | null>;
1049
+ /**
1050
+ * After every authoritative exact-CID revoke succeeds, revalidate the
1051
+ * marker's principal and version-specific fence under the profile lock:
1052
+ * v1 `deniedEpoch`/hash, v2 no replacement gate, or v3 exact inactive
1053
+ * epoch/null principal. Then durably remove `remotePending`. A marker that is
1054
+ * not remote-pending (remote-abandoned or already cleanup-ready) refuses
1055
+ * without mutation. A failed/partial remote sequence never calls this.
1056
+ */
1057
+ completeRemoteLogout(input: {
1058
+ principal: string;
1059
+ }): Promise<ProfileReplicaLogoutPending>;
1060
+ /**
1061
+ * Under the profile lock, report whether this principal's marker is still
1062
+ * remote-pending and its v1 deferred gate, v2 orphan or v3 inactive gate
1063
+ * fence still matches. False only when the marker is absent/completed;
1064
+ * a mismatched principal or stale gate refuses. This permits an explicit
1065
+ * network-only revoke retry, never replica delivery.
1066
+ */
1067
+ remotePendingLogout(input: {
1068
+ principal: string;
1069
+ }): Promise<boolean>;
1070
+ /**
1071
+ * First explicit PLAIN logout attempt after unfinished remote revoke:
1072
+ * compare the original durable approved inventory with the current
1073
+ * path/type/dev/ino set under the profile lock. Any change refuses with
1074
+ * `REPLICA_GATED`/`gate_unavailable`, prints every token-bound
1075
+ * `ProfileReplicaApprovalEntry`, exposes the same complete set as
1076
+ * `error.inventoryApprovalEntries` plus a nonsecret 32-hex-character
1077
+ * `error.inventoryApprovalToken`, and mutates nothing even when `approval`
1078
+ * contains the new paths or `discardLocalChanges` is true. With the
1079
+ * original inventory intact, validate the fresh approval and the marker's
1080
+ * version-specific owner/gate fence (a v1 marker is classified by its gate
1081
+ * and checked exactly one way, CONTRACT.md §2), then atomically replace
1082
+ * `remotePending` with `remoteAbandoned: true`.
1083
+ * This is not an online-success receipt and makes no further node call.
1084
+ */
1085
+ abandonRemoteLogout(input: {
1086
+ principal: string;
1087
+ approval: ProfileReplicaPreflight;
1088
+ discardLocalChanges?: boolean;
1089
+ }): Promise<ProfileReplicaLogoutPending>;
1090
+ /**
1091
+ * Only a separate explicit CLI command with
1092
+ * `--discard-local-changes --approve-inventory <token>` may approve a
1093
+ * changed remote-pending inventory. Under the profile lock, require the
1094
+ * same owner and version-specific gate fence, original-to-current inventory
1095
+ * mismatch, exact fresh preflight and matching displayed 32-hex-character
1096
+ * token. Commit the newly approved identities with `remoteAbandoned: true`,
1097
+ * never a remote receipt. Missing, stale or reused tokens fail without
1098
+ * mutation; there is no same-command fallback from `abandonRemoteLogout`.
1099
+ */
1100
+ approveChangedPlainLogout(input: {
1101
+ principal: string;
1102
+ approval: ProfileReplicaPreflight;
1103
+ discardLocalChanges: true;
1104
+ inventoryApprovalToken: string;
1105
+ }): Promise<ProfileReplicaLogoutPending>;
1106
+ /**
1107
+ * After a completed remote sequence, an inventory mismatch leaves the
1108
+ * marker and signed-out state in place. A fresh explicit listing/ack under
1109
+ * the profile lock replaces only the approved inventory; remote revokes
1110
+ * are not repeated, and a still-pending remote sequence cannot reapprove.
1111
+ */
1112
+ reapproveLogoutPending(input: {
1113
+ principal: string;
1114
+ approval: ProfileReplicaPreflight;
1115
+ }): Promise<ProfileReplicaLogoutPending>;
1116
+ /**
1117
+ * CLI startup/logout recovery under the same profile lock. A `remotePending`
1118
+ * marker returns failure with no credential or file deletion. For a
1119
+ * cleanup-ready active-v1 marker, validate the gate and durably commit
1120
+ * generation-fenced denial with complete partition/pending intents
1121
+ * BEFORE calling `clearCredentials`; denial failure leaves the marker,
1122
+ * both credentials and replica files intact and reports nonzero failure.
1123
+ * An already-deferred v1 validates its exact denial; v3 validates its
1124
+ * inactive epoch and v2 its orphan/no-replacement-gate fence without
1125
+ * inventing scoped intents. Only then does `clearCredentials` atomically
1126
+ * fsync CLI authSignedOut before unlinking and fsyncing both session.json
1127
+ * and key.json. Recheck durable approved identities before each unlink;
1128
+ * mismatch stops with marker, remaining files and nonzero failure.
1129
+ * Post-success credentials remain shredded. The callback erases only its
1130
+ * approved inventory under the profile lock (never recursive legacy
1131
+ * deletion). In-repo CLI code owns the signed-out postcondition; the
1132
+ * facade verifies credential absence before erasure. A missing marker
1133
+ * with corrupt/unreadable gate reports failure and nonzero remaining,
1134
+ * without invoking callbacks or fabricating successful completion.
1135
+ */
1136
+ recoverLogoutPending(options: {
1137
+ signal: AbortSignal;
1138
+ clearCredentials: () => Promise<void>;
1139
+ eraseLegacyReplicas: (options: {
1140
+ discardLocalChanges: boolean;
1141
+ approvedUnknown: readonly ProfileReplicaUnknownEntry[];
1142
+ knownRoots: readonly ProfileReplicaRootIdentity[];
1143
+ knownEntries: readonly ProfileReplicaUnknownEntry[];
1144
+ missingDatabases: readonly string[];
1145
+ }) => Promise<void>;
1146
+ }): Promise<ReplicaEraseReport>;
1147
+ }
856
1148
  /** Worker replies that carry replica-served data are stamped; a reply without a stamp is dropped. */
857
1149
  interface ReplicaStampedReply {
858
1150
  stamp: ReplicaSessionStamp;
@@ -1315,10 +1607,13 @@ interface ReplicationAuthority {
1315
1607
  mint(deviceDid: string, prefix: string, signal: AbortSignal,
1316
1608
  /**
1317
1609
  * `expiryMs` is a DURATION from now (forwarded as `delegateTo(…, { expiry: expiryMs })`), from
1318
- * `mintRemainingMs`. Absent means Phase 1 behaviour (session-tier default).
1610
+ * `mintRemainingMs`. Absent means Phase 1 behaviour (session-tier default). `renewalOnly`
1611
+ * requires the authority to recheck a session-key parent at mint time; an initial runtime
1612
+ * parent mint leaves it unset.
1319
1613
  */
1320
1614
  options?: {
1321
1615
  expiryMs?: number;
1616
+ renewalOnly?: boolean;
1322
1617
  }): Promise<{
1323
1618
  ucan: string;
1324
1619
  parentCid: string;
@@ -1425,6 +1720,12 @@ interface KVReadThrough {
1425
1720
  }): void;
1426
1721
  }
1427
1722
  interface KVReplicationController extends KVReadThrough {
1723
+ /**
1724
+ * Internal browser gate observer: invoked only after the stamped durable consent-expiry
1725
+ * denial/deletion commits. Records opened opaque grant transitions even if the gate's own
1726
+ * timer wins before this controller's timer; does not issue a second deny.
1727
+ */
1728
+ observeConsentExpired(stamp: ReplicaSessionStamp): void;
1428
1729
  status(): Promise<ReplicaStatusEntry[]>;
1429
1730
  sync(o?: {
1430
1731
  prefix?: string;
@@ -1525,7 +1826,9 @@ interface ReplicaStatusEntry extends Partial<LocalReplicaStatus> {
1525
1826
  type ReplicaState = "idle" | "ready" | "grant_missing" | "runtime_unsupported" | "unavailable" | "revoked"
1526
1827
  /** The lifecycle gate refuses this session (REPLICA_GATED); reads use the network. */
1527
1828
  | "gated" | "closed";
1528
- type ReplicationReason = "hit" | "absent" | "deleted" | "content_missing" | "coverage_incomplete" | "not_covered" | "pending_write" | "grant_missing" | "grant_expired" | "grant_revoked" | "grant_not_yet_valid" | "stale" | "replica_error" | "runtime_unsupported" | "replica_unavailable" | "unsupported_option" | "network_cursor" | "cursor_restart" | "aborted" | "REPLICA_BEHIND_OWN_WRITES" | "REPLICA_UNPROVEN_SINCE_START" | "NETWORK_REQUESTED"
1829
+ type ReplicationReason = "hit" | "absent" | "deleted" | "content_missing" | "coverage_incomplete" | "not_covered" | "pending_write" | "grant_missing" | "grant_expired"
1830
+ /** A revoked grant, including a stale-read sync refusal; read-through uses network with code GRANT_REVOKED. */
1831
+ | "grant_revoked" | "grant_not_yet_valid" | "stale" | "replica_error" | "runtime_unsupported" | "replica_unavailable" | "unsupported_option" | "network_cursor" | "cursor_restart" | "aborted" | "REPLICA_BEHIND_OWN_WRITES" | "REPLICA_UNPROVEN_SINCE_START" | "NETWORK_REQUESTED"
1529
1832
  /** The lifecycle gate refused (REPLICA_GATED); the read went to the network. */
1530
1833
  | "replica_gated";
1531
1834
  type ReplicationEvent = {
@@ -1656,4 +1959,4 @@ declare function replicationIdentityKey(id: ReplicationIdentity): string;
1656
1959
  /** Whether replicating this space/prefix can copy encrypted secret material to disk. */
1657
1960
  declare function requiresSecretsOptIn(space: string, prefix: string): boolean;
1658
1961
 
1659
- export { type ReplicaQuotaLedger as $, type AuthorityRefusal as A, type ReplicaDevice as B, CLEAR_PENDING_WARNING as C, type ReplicaEraseReport as D, type ReplicaGateAccess as E, type ReplicaGateReason as F, type ReplicaGateScope as G, type ReplicaGateSnapshot as H, type ReplicaGateTx as I, type ReplicaGatedError as J, type KVReplicationDeps as K, type LocalGetResult as L, type ReplicaGatedHandle as M, type ReplicaGatedSpec as N, type ReplicaGatedStorage as O, type PendingWriteStore as P, type ReplicaGrantInfo as Q, type ReplicationIdentity as R, type ReplicaGuardedStore as S, type ReplicaInspectedStatus as T, type ReplicaLifecycleGate as U, type ReplicaPageAdmission as V, type ReplicaPartitionEraser as W, type ReplicaPartitionIndexEntry as X, type ReplicaPeerCommit as Y, type ReplicaPrivateGateHash as Z, type ReplicaPurgeIntent as _, type KVReplicationController as a, type ReplicaQuotaSpec as a0, type ReplicaSessionStamp as a1, type ReplicaStampedReply as a2, type ReplicaState as a3, type ReplicaStatusEntry as a4, type ReplicationAuthority as a5, type ReplicationControl as a6, type ReplicationEvent as a7, type ReplicationOptions as a8, type ReplicationPurgeReport as a9, type KVResponseHeaders as aA, type KVServiceConfig as aB, type KVSignedReadUrlResponse as aC, type ReplicationReason as aa, type ReplicationScheduler as ab, type ResolvedReplicationOptions as ac, type SaltedHash as ad, canonicalReplicationIdentity as ae, replicationIdentityKey as af, requiresSecretsOptIn as ag, DEFAULT_SIGNED_READ_URL_EXPIRY_MS as ah, KVAction as ai, type KVActionType as aj, type KVBatchPutItem as ak, type KVBatchPutOptions as al, type KVBatchPutResponse as am, type KVBatchReadResponse as an, type KVChange as ao, type KVChangesAuthority as ap, type KVChangesOptions as aq, type KVChangesResponse as ar, type KVCreateSignedReadUrlOptions as as, type KVDeleteOptions as at, type KVGetOptions as au, type KVHeadOptions as av, type KVListOptions as aw, type KVListResponse as ax, type KVPutOptions as ay, type KVResponse as az, type KVListPage as b, type KVReadThrough as c, type KVReplicaHandle as d, type KVReplicaSpec as e, type KVReplicaStorage as f, type LocalListResult as g, type LocalReadMeta as h, type LocalReplicaStatus as i, type LocalSyncResult as j, type PendingWriteRecord as k, type PendingWriteState as l, type PinnedKey as m, type PurgeTarget as n, type ReplicaAccountGate as o, type ReplicaAdmitHook as p, type ReplicaAuthorityState as q, type ReplicaBrowserGateAccess as r, type ReplicaBrowserGateSnapshot as s, type ReplicaBrowserGateTx as t, type ReplicaBrowserPageAdmission as u, type ReplicaClock as v, type ReplicaCoverage as w, type ReplicaDeliveryDecision as x, type ReplicaDenyInput as y, type ReplicaDenyOutcome as z };
1962
+ export { type ReplicaGuardedStore as $, type AuthorityRefusal as A, type ReplicaBrowserGateAccess as B, CLEAR_PENDING_WARNING as C, type ReplicaBrowserGateSnapshot as D, type ReplicaBrowserGateTx as E, type ReplicaBrowserPageAdmission as F, type ReplicaClock as G, type ReplicaCoverage as H, type ReplicaDeliveryDecision as I, type ReplicaDenyInput as J, type KVReplicationDeps as K, type LocalGetResult as L, type ReplicaDenyOutcome as M, type ReplicaDevice as N, type ReplicaEraseReport as O, type PendingWriteStore as P, type ReplicaGateAccess as Q, type ReplicationIdentity as R, type ReplicaGateReason as S, type ReplicaGateScope as T, type ReplicaGateSnapshot as U, type ReplicaGateTx as V, type ReplicaGatedError as W, type ReplicaGatedHandle as X, type ReplicaGatedSpec as Y, type ReplicaGatedStorage as Z, type ReplicaGrantInfo as _, type KVReplicationController as a, type ReplicaInspectedStatus as a0, type ReplicaLifecycleGate as a1, type ReplicaPageAdmission as a2, type ReplicaPartitionEraser as a3, type ReplicaPartitionIndexEntry as a4, type ReplicaPeerCommit as a5, type ReplicaPrivateGateHash as a6, type ReplicaPurgeIntent as a7, type ReplicaQuotaLedger as a8, type ReplicaQuotaSpec as a9, type KVChangesResponse as aA, type KVCreateSignedReadUrlOptions as aB, type KVDeleteOptions as aC, type KVGetOptions as aD, type KVHeadOptions as aE, type KVListOptions as aF, type KVListResponse as aG, type KVPutOptions as aH, type KVResponse as aI, type KVResponseHeaders as aJ, type KVServiceConfig as aK, type KVSignedReadUrlResponse as aL, type ReplicaSessionStamp as aa, type ReplicaStampedReply as ab, type ReplicaState as ac, type ReplicaStatusEntry as ad, type ReplicationAuthority as ae, type ReplicationControl as af, type ReplicationEvent as ag, type ReplicationOptions as ah, type ReplicationPurgeReport as ai, type ReplicationReason as aj, type ReplicationScheduler as ak, type ResolvedReplicationOptions as al, type SaltedHash as am, canonicalReplicationIdentity as an, replicationIdentityKey as ao, requiresSecretsOptIn as ap, DEFAULT_SIGNED_READ_URL_EXPIRY_MS as aq, KVAction as ar, type KVActionType as as, type KVBatchPutItem as at, type KVBatchPutOptions as au, type KVBatchPutResponse as av, type KVBatchReadResponse as aw, type KVChange as ax, type KVChangesAuthority as ay, type KVChangesOptions as az, type KVListPage as b, type KVReadThrough as c, type KVReplicaHandle as d, type KVReplicaSpec as e, type KVReplicaStorage as f, type LocalListResult as g, type LocalReadMeta as h, type LocalReplicaStatus as i, type LocalSyncResult as j, type PendingWriteRecord as k, type PendingWriteState as l, type PinnedKey as m, type ProfileReplicaApprovalEntry as n, type ProfileReplicaDeferredDenyOutcome as o, type ProfileReplicaDeferredToken as p, type ProfileReplicaLogoutPending as q, type ProfileReplicaPreflight as r, type ProfileReplicaPurge as s, type ProfileReplicaRemoteLogoutState as t, type ProfileReplicaRootIdentity as u, type ProfileReplicaUnknownEntry as v, type PurgeTarget as w, type ReplicaAccountGate as x, type ReplicaAdmitHook as y, type ReplicaAuthorityState as z };
@@ -43,6 +43,14 @@ var ErrorCodes = {
43
43
  ABORTED: "ABORTED",
44
44
  INVALID_INPUT: "INVALID_INPUT",
45
45
  PERMISSION_DENIED: "PERMISSION_DENIED",
46
+ // Node retry signals (tinycloud-node #347). `meta.retryable` says whether a
47
+ // retry is safe; `meta.retryAfterMs` carries the node's Retry-After.
48
+ /** HTTP 503 `sql_busy:<stage>` / `unavailable_retryable`: nothing was applied; retry after `meta.retryAfterMs`. */
49
+ NODE_BUSY: "NODE_BUSY",
50
+ /** HTTP 503 `request_timeout`: the node's deadline passed before any write began; nothing was applied. */
51
+ REQUEST_TIMEOUT: "REQUEST_TIMEOUT",
52
+ /** HTTP 500 `outcome_unknown`: a write may have been applied; read back before retrying a non-idempotent operation. */
53
+ OUTCOME_UNKNOWN: "OUTCOME_UNKNOWN",
46
54
  // KV-specific errors
47
55
  KV_NOT_FOUND: "KV_NOT_FOUND",
48
56
  KV_WRITE_FAILED: "KV_WRITE_FAILED",
@@ -118,6 +126,42 @@ function serviceError(code, message, service, options) {
118
126
  };
119
127
  }
120
128
 
129
+ // src/responseErrors.ts
130
+ function parseServiceErrorBody(errorText) {
131
+ try {
132
+ return JSON.parse(errorText);
133
+ } catch {
134
+ return {};
135
+ }
136
+ }
137
+ function formatServiceResponseError(serviceLabel, operation, status, errorText, parsed) {
138
+ if (parsed.message) {
139
+ return compactMessage(parsed.message);
140
+ }
141
+ if (looksLikeHtml(errorText)) {
142
+ if (status === 524 || /524\s*[:-]/i.test(errorText) || /a timeout occurred/i.test(errorText)) {
143
+ return `${serviceLabel} ${operation} failed: upstream request timed out. Please retry.`;
144
+ }
145
+ return `${serviceLabel} ${operation} failed: upstream service returned an HTML error page (${status}).`;
146
+ }
147
+ return `${serviceLabel} ${operation} failed: ${status} - ${compactMessage(errorText)}`;
148
+ }
149
+ function responseErrorMeta(status, statusText, errorText) {
150
+ const meta = { status, statusText };
151
+ const snippet = compactMessage(errorText);
152
+ if (snippet) {
153
+ meta.responseSnippet = snippet.slice(0, 300);
154
+ }
155
+ return meta;
156
+ }
157
+ function looksLikeHtml(text) {
158
+ return /<!doctype html/i.test(text) || /<html[\s>]/i.test(text);
159
+ }
160
+ function compactMessage(message) {
161
+ const compact = message.replace(/\s+/g, " ").trim();
162
+ return compact.length > 500 ? `${compact.slice(0, 500)}...` : compact;
163
+ }
164
+
121
165
  // src/errors.ts
122
166
  var import_bootstrap = require("@tinycloud/bootstrap");
123
167
  function authRequiredError(service) {
@@ -127,6 +171,30 @@ function authRequiredError(service) {
127
171
  service
128
172
  };
129
173
  }
174
+ function nodeRetryError(service, message, response, errorText) {
175
+ const body = errorText.trim();
176
+ const [, token, stage] = /^([a-z_]+)(?::\s*([a-z_]+):)?/.exec(body) ?? [];
177
+ let code;
178
+ if (response.status === 503 && (token === "sql_busy" || token === "unavailable_retryable")) {
179
+ code = ErrorCodes.NODE_BUSY;
180
+ } else if (response.status === 503 && token === "request_timeout") {
181
+ code = ErrorCodes.REQUEST_TIMEOUT;
182
+ } else if (response.status === 500 && token === "outcome_unknown") {
183
+ code = ErrorCodes.OUTCOME_UNKNOWN;
184
+ } else {
185
+ return void 0;
186
+ }
187
+ const retryable = code !== ErrorCodes.OUTCOME_UNKNOWN;
188
+ const retryAfter = response.headers.get("retry-after")?.trim();
189
+ return serviceError(code, message, service, {
190
+ meta: {
191
+ ...responseErrorMeta(response.status, response.statusText, errorText),
192
+ retryable,
193
+ ...token === "sql_busy" && stage ? { stage } : {},
194
+ ...retryable && retryAfter && /^\d{1,9}$/.test(retryAfter) ? { retryAfterMs: Number(retryAfter) * 1e3 } : {}
195
+ }
196
+ });
197
+ }
130
198
  var RequestTimeoutError = class extends Error {
131
199
  constructor(timeoutMs) {
132
200
  super(`Request timed out after ${timeoutMs}ms.`);
@@ -534,42 +602,6 @@ var BaseService = class {
534
602
  }
535
603
  };
536
604
 
537
- // src/responseErrors.ts
538
- function parseServiceErrorBody(errorText) {
539
- try {
540
- return JSON.parse(errorText);
541
- } catch {
542
- return {};
543
- }
544
- }
545
- function formatServiceResponseError(serviceLabel, operation, status, errorText, parsed) {
546
- if (parsed.message) {
547
- return compactMessage(parsed.message);
548
- }
549
- if (looksLikeHtml(errorText)) {
550
- if (status === 524 || /524\s*[:-]/i.test(errorText) || /a timeout occurred/i.test(errorText)) {
551
- return `${serviceLabel} ${operation} failed: upstream request timed out. Please retry.`;
552
- }
553
- return `${serviceLabel} ${operation} failed: upstream service returned an HTML error page (${status}).`;
554
- }
555
- return `${serviceLabel} ${operation} failed: ${status} - ${compactMessage(errorText)}`;
556
- }
557
- function responseErrorMeta(status, statusText, errorText) {
558
- const meta = { status, statusText };
559
- const snippet = compactMessage(errorText);
560
- if (snippet) {
561
- meta.responseSnippet = snippet.slice(0, 300);
562
- }
563
- return meta;
564
- }
565
- function looksLikeHtml(text) {
566
- return /<!doctype html/i.test(text) || /<html[\s>]/i.test(text);
567
- }
568
- function compactMessage(message) {
569
- const compact = message.replace(/\s+/g, " ").trim();
570
- return compact.length > 500 ? `${compact.slice(0, 500)}...` : compact;
571
- }
572
-
573
605
  // src/sql/SQLMigrations.ts
574
606
  var SQLMigrations = class {
575
607
  constructor(service, dbName) {
@@ -985,6 +1017,8 @@ var SQLService = class extends BaseService {
985
1017
  if (response.status === 413 && parseStorageRejection(errorText)) {
986
1018
  return err(storageRejectionError("sql", 413, meta, errorText));
987
1019
  }
1020
+ const retryError = nodeRetryError("sql", `SQL ${operation} failed: ${response.status} - ${errorText}`, response, errorText);
1021
+ if (retryError) return err(retryError);
988
1022
  const errorBody = parseServiceErrorBody(errorText);
989
1023
  const errorCode = this.mapHttpStatusToErrorCode(
990
1024
  response.status,