@tryterra/react-native 0.0.0-stage → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +111 -2
- package/TerraReactNative.podspec +48 -0
- package/android/build.gradle +87 -0
- package/android/src/bridgeCore/kotlin/README.md +1 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/BridgeBootstrap.kt +133 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/BridgeHost.kt +32 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/Conversions.kt +468 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/LauncherSlots.kt +41 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/Rejections.kt +150 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/StreamRegistry.kt +77 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridge.kt +86 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridgeOpsImpl.kt +258 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridgeStreamsImpl.kt +52 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TokenBroker.kt +198 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeErrors.kt +217 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeOps.kt +493 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeTypes.kt +897 -0
- package/android/src/healthConnectOnly/kotlin/co/tryterra/sdk/reactnative/TerraStores.kt +11 -0
- package/android/src/main/AndroidManifest.xml +24 -0
- package/android/src/main/java/co/tryterra/sdk/reactnative/TerraBridgeModule.kt +96 -0
- package/android/src/main/java/co/tryterra/sdk/reactnative/TerraReactNativePackage.kt +26 -0
- package/android/src/main/java/co/tryterra/sdk/reactnative/TerraStartup.kt +46 -0
- package/android/src/samsung/kotlin/co/tryterra/sdk/reactnative/TerraStores.kt +13 -0
- package/docs/availability.md +246 -0
- package/frameworks/TerraSDK.xcframework/Info.plist +48 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Info.plist +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.abi.json +81513 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.package.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.swiftinterface +2127 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/PrivacyInfo.xcprivacy +96 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/TerraSDK +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Info.plist +20 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/DWARF/TerraSDK +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/TerraSDK.yml +6473 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Info.plist +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +81513 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.package.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +2127 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +81513 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.package.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +2127 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/PrivacyInfo.xcprivacy +96 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/TerraSDK +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/_CodeSignature/CodeResources +223 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Info.plist +20 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/DWARF/TerraSDK +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/TerraSDK.yml +6474 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/x86_64/TerraSDK.yml +6392 -0
- package/ios/TerraBridgeCore/BridgeBootstrap.swift +89 -0
- package/ios/TerraBridgeCore/BridgeHost.swift +13 -0
- package/ios/TerraBridgeCore/Conversions.swift +467 -0
- package/ios/TerraBridgeCore/Generated/BridgeErrors.swift +179 -0
- package/ios/TerraBridgeCore/Generated/BridgeOps.swift +795 -0
- package/ios/TerraBridgeCore/Generated/BridgeTypes.swift +1317 -0
- package/ios/TerraBridgeCore/Locked.swift +15 -0
- package/ios/TerraBridgeCore/README.md +1 -0
- package/ios/TerraBridgeCore/Rejections.swift +130 -0
- package/ios/TerraBridgeCore/RuntimeAttachment.swift +60 -0
- package/ios/TerraBridgeCore/StreamRegistry.swift +59 -0
- package/ios/TerraBridgeCore/TerraBridge.swift +122 -0
- package/ios/TerraBridgeCore/TerraBridgeOpsImpl.swift +254 -0
- package/ios/TerraBridgeCore/TerraBridgeStreamsImpl.swift +60 -0
- package/ios/TerraBridgeCore/TokenBroker.swift +147 -0
- package/ios/TerraBridgeModule.mm +79 -0
- package/ios/TerraBridgeModuleImpl.swift +49 -0
- package/ios/TerraReactNative.swift +19 -0
- package/lib/EndUser.d.ts +12 -0
- package/lib/EndUser.js +13 -0
- package/lib/NativeTerraBridge.d.ts +23 -0
- package/lib/NativeTerraBridge.js +13 -0
- package/lib/Terra.d.ts +154 -0
- package/lib/Terra.js +280 -0
- package/lib/TerraError.d.ts +42 -0
- package/lib/TerraError.js +95 -0
- package/lib/createTerra.d.ts +8 -0
- package/lib/createTerra.js +19 -0
- package/lib/generated/client.d.ts +92 -0
- package/lib/generated/client.js +196 -0
- package/lib/generated/codecs.d.ts +135 -0
- package/lib/generated/codecs.js +1358 -0
- package/lib/generated/errors.d.ts +13 -0
- package/lib/generated/errors.js +68 -0
- package/lib/generated/ops.d.ts +13 -0
- package/lib/generated/ops.js +137 -0
- package/lib/generated/streams.d.ts +23 -0
- package/lib/generated/streams.js +58 -0
- package/lib/generated/types.d.ts +481 -0
- package/lib/generated/types.js +92 -0
- package/lib/index.d.ts +29 -0
- package/lib/index.js +55 -0
- package/package.json +71 -4
- package/src/EndUser.ts +15 -0
- package/src/NativeTerraBridge.ts +27 -0
- package/src/Terra.ts +462 -0
- package/src/TerraError.ts +116 -0
- package/src/createTerra.ts +18 -0
- package/src/generated/client.ts +243 -0
- package/src/generated/codecs.ts +1287 -0
- package/src/generated/errors.ts +113 -0
- package/src/generated/ops.ts +197 -0
- package/src/generated/streams.ts +74 -0
- package/src/generated/types.ts +667 -0
- package/src/index.ts +52 -0
package/package.json
CHANGED
|
@@ -1,6 +1,73 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tryterra/react-native",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Terra's health SDK for React Native",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://docs.tryterra.co",
|
|
7
|
+
"author": "Terra",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/tryterra/terra-mobile.git",
|
|
11
|
+
"directory": "react-native"
|
|
12
|
+
},
|
|
13
|
+
"main": "lib/index.js",
|
|
14
|
+
"types": "lib/index.d.ts",
|
|
15
|
+
"react-native": "src/index.ts",
|
|
16
|
+
"source": "src/index.ts",
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"!src/__tests__",
|
|
20
|
+
"lib",
|
|
21
|
+
"ios",
|
|
22
|
+
"frameworks",
|
|
23
|
+
"android",
|
|
24
|
+
"!android/build",
|
|
25
|
+
"!android/.gradle",
|
|
26
|
+
"!android/.cxx",
|
|
27
|
+
"TerraReactNative.podspec",
|
|
28
|
+
"docs",
|
|
29
|
+
"!docs/distribution.md"
|
|
30
|
+
],
|
|
31
|
+
"terraNative": {
|
|
32
|
+
"ios": "2.1.0",
|
|
33
|
+
"android": "2.1.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"test": "jest",
|
|
38
|
+
"build": "node scripts/clean-lib.js && tsc -p tsconfig.build.json",
|
|
39
|
+
"vendor": "node scripts/vendor-bridge-core.js",
|
|
40
|
+
"fetch-sdk": "node scripts/fetch-terrasdk.js",
|
|
41
|
+
"prepack": "npm run build && npm run vendor && npm run fetch-sdk"
|
|
42
|
+
},
|
|
43
|
+
"codegenConfig": {
|
|
44
|
+
"name": "TerraReactNativeSpec",
|
|
45
|
+
"type": "modules",
|
|
46
|
+
"jsSrcsDir": "src",
|
|
47
|
+
"android": {
|
|
48
|
+
"javaPackageName": "co.tryterra.sdk.reactnative"
|
|
49
|
+
},
|
|
50
|
+
"ios": {
|
|
51
|
+
"modulesProvider": {
|
|
52
|
+
"TerraBridge": "TerraBridgeModule"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"react-native": ">=0.81.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@babel/core": "7.26.0",
|
|
61
|
+
"@babel/preset-env": "7.26.0",
|
|
62
|
+
"@babel/preset-typescript": "7.26.0",
|
|
63
|
+
"@types/jest": "29.5.14",
|
|
64
|
+
"babel-jest": "29.7.0",
|
|
65
|
+
"jest": "29.7.0",
|
|
66
|
+
"react-native": "0.81.6",
|
|
67
|
+
"typescript": "5.9.2"
|
|
68
|
+
},
|
|
69
|
+
"publishConfig": {
|
|
70
|
+
"access": "public",
|
|
71
|
+
"registry": "https://registry.npmjs.org/"
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/EndUser.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who a connect is for. Every connect names one: a second account on the same phone is a different
|
|
3
|
+
* person, and must not be handed the first one's link.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { EndUser as WireEndUser } from "./generated/types";
|
|
7
|
+
|
|
8
|
+
export type EndUser = WireEndUser;
|
|
9
|
+
|
|
10
|
+
export const EndUser = {
|
|
11
|
+
/** The person your backend knows as `id`: the same value it mints their token with. */
|
|
12
|
+
reference: (id: string): EndUser => ({ kind: "reference", id }),
|
|
13
|
+
/** Nobody your backend tracks. Every anonymous connect on one device is the same person. */
|
|
14
|
+
anonymous: { kind: "anonymous" } as EndUser,
|
|
15
|
+
} as const;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TurboModule spec. React Native's Codegen reads this file at the host app's build and
|
|
3
|
+
* generates the native interfaces that `ios/TerraBridgeModule.mm` and
|
|
4
|
+
* `android/.../TerraBridgeModule.kt` implement.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately narrow: one op call, one stream subscription, one emitter. The op strings and their
|
|
7
|
+
* payloads are typed on both sides by `surface/gen`, so nothing here changes when the SDK grows an
|
|
8
|
+
* op.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CodegenTypes, TurboModule } from "react-native";
|
|
12
|
+
import { TurboModuleRegistry } from "react-native";
|
|
13
|
+
|
|
14
|
+
export type StreamMessageEvent = {
|
|
15
|
+
subscriptionId: string;
|
|
16
|
+
/** A `StreamMessage`, as the wire spells it. */
|
|
17
|
+
message: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface Spec extends TurboModule {
|
|
21
|
+
invoke(op: string, payload: string): Promise<string>;
|
|
22
|
+
subscribe(stream: string, params: string): Promise<string>;
|
|
23
|
+
unsubscribe(subscriptionId: string): void;
|
|
24
|
+
readonly onMessage: CodegenTypes.EventEmitter<StreamMessageEvent>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default TurboModuleRegistry.getEnforcing<Spec>("TerraBridge");
|
package/src/Terra.ts
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client an app holds: namespaces over the generated ops, the token provider, and the streams.
|
|
3
|
+
*
|
|
4
|
+
* Hand-written, deliberately thin. Every method is one generated `GeneratedClient` call; what lives
|
|
5
|
+
* here is only what the generated layer cannot know - which runtime the app is on, how a JS
|
|
6
|
+
* function answers a native token request, and how one native event emitter fans out to many
|
|
7
|
+
* listeners. `facade.test.ts` drives every method and fails when an op in the schema has no member
|
|
8
|
+
* here, so this file cannot quietly fall behind the surface the way the 1.x wrapper did.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { TerraError } from "./TerraError";
|
|
12
|
+
import { GeneratedClient, type Invoker } from "./generated/client";
|
|
13
|
+
import { availableOn, type OpId, type StreamId } from "./generated/ops";
|
|
14
|
+
import { decodeStreamDelivery, type StreamElements } from "./generated/streams";
|
|
15
|
+
import type {
|
|
16
|
+
BodyEntry,
|
|
17
|
+
BootstrapState,
|
|
18
|
+
Connection,
|
|
19
|
+
DataCategory,
|
|
20
|
+
DiagnosticsState,
|
|
21
|
+
EndUser,
|
|
22
|
+
HealthAccess,
|
|
23
|
+
HealthChange,
|
|
24
|
+
HeartRateEntry,
|
|
25
|
+
HeartRateSample,
|
|
26
|
+
NutritionEntry,
|
|
27
|
+
PermissionStatus,
|
|
28
|
+
PlannedWorkout,
|
|
29
|
+
WorkoutSchedulingStatus,
|
|
30
|
+
SleepEntry,
|
|
31
|
+
SleepSegment,
|
|
32
|
+
Source,
|
|
33
|
+
StepCount,
|
|
34
|
+
StepEntry,
|
|
35
|
+
SupportReport,
|
|
36
|
+
TerraEvent,
|
|
37
|
+
TimeRange,
|
|
38
|
+
Workout,
|
|
39
|
+
WorkoutEntry,
|
|
40
|
+
WorkoutScope,
|
|
41
|
+
} from "./generated/types";
|
|
42
|
+
|
|
43
|
+
/** What the facade needs from the native module. `NativeTerraBridge` satisfies it; so does a double. */
|
|
44
|
+
export interface NativeBridge {
|
|
45
|
+
invoke(op: string, payload: string): Promise<string>;
|
|
46
|
+
subscribe(stream: string, params: string): Promise<string>;
|
|
47
|
+
unsubscribe(subscriptionId: string): void;
|
|
48
|
+
onMessage(listener: (event: { subscriptionId: string; message: string }) => void): { remove(): void };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type Platform = "ios" | "android";
|
|
52
|
+
|
|
53
|
+
/** Asked for a one-shot link token, and only while `connect` establishes a new link. */
|
|
54
|
+
export type TokenProvider = () => Promise<string>;
|
|
55
|
+
|
|
56
|
+
export interface TerraOptions {
|
|
57
|
+
/**
|
|
58
|
+
* Called only when `connect` has to make a new link and was given no token. A restored link
|
|
59
|
+
* never asks, and neither does a background wake: uploads are signed by the device key.
|
|
60
|
+
*/
|
|
61
|
+
readonly tokenProvider?: TokenProvider;
|
|
62
|
+
/** How long the native side waits for `tokenProvider`. 30 seconds by default. */
|
|
63
|
+
readonly tokenTimeoutMs?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type Unsubscribe = () => void;
|
|
67
|
+
|
|
68
|
+
/** A stream's listener. `onError` receives the error a stream ended with; a stream that completes ends quietly. */
|
|
69
|
+
export interface StreamListener<T> {
|
|
70
|
+
(element: T): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Permissions {
|
|
74
|
+
/**
|
|
75
|
+
* One sheet covering both directions and everything asked before. Android answers what this call
|
|
76
|
+
* obtained; HealthKit will not report read grants, so iOS answers `undefined`.
|
|
77
|
+
*
|
|
78
|
+
* `source` defaults to `health`. `samsungHealth` presents Samsung Health's own consent popup and
|
|
79
|
+
* is Android only.
|
|
80
|
+
*/
|
|
81
|
+
request(access: Partial<HealthAccess>, source?: Source): Promise<HealthAccess | undefined>;
|
|
82
|
+
status(category: DataCategory, source?: Source): Promise<PermissionStatus>;
|
|
83
|
+
writeStatus(category: DataCategory, source?: Source): Promise<PermissionStatus>;
|
|
84
|
+
requested(source?: Source): Promise<HealthAccess>;
|
|
85
|
+
/** Android only. */
|
|
86
|
+
readonly statuses?: (source?: Source) => Promise<Partial<Record<DataCategory, PermissionStatus>>>;
|
|
87
|
+
/** Android only. */
|
|
88
|
+
readonly backgroundReadStatus?: (source?: Source) => Promise<PermissionStatus>;
|
|
89
|
+
/** Android only. */
|
|
90
|
+
readonly historyReadStatus?: (source?: Source) => Promise<PermissionStatus>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface Diagnostics {
|
|
94
|
+
state(source?: Source): Promise<DiagnosticsState>;
|
|
95
|
+
lastDelivery(source?: Source): Promise<Date | undefined>;
|
|
96
|
+
report(source?: Source): Promise<SupportReport>;
|
|
97
|
+
send(source?: Source): Promise<void>;
|
|
98
|
+
/** Everything the SDK does, as it happens. Never carries a health value. */
|
|
99
|
+
events(listener: StreamListener<TerraEvent>, onError?: (error: TerraError) => void): Unsubscribe;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface Background {
|
|
103
|
+
/** Whether the native bootstrap registered background sync at launch. False is a host setup bug. */
|
|
104
|
+
isRegistered(): Promise<boolean>;
|
|
105
|
+
/** Android only: drops the schedule. */
|
|
106
|
+
readonly cancel?: () => Promise<boolean>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface Changes {
|
|
110
|
+
/** Which days changed in the health store. Re-read through this namespace's accessor. */
|
|
111
|
+
changes(listener: StreamListener<HealthChange>, onError?: (error: TerraError) => void): Unsubscribe;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface BodyMetrics extends Changes {
|
|
115
|
+
heartRate(range: TimeRange): Promise<readonly HeartRateSample[]>;
|
|
116
|
+
logHeartRate(entries: readonly HeartRateEntry[]): Promise<readonly string[]>;
|
|
117
|
+
log(entries: readonly BodyEntry[]): Promise<readonly string[]>;
|
|
118
|
+
delete(id: string): Promise<void>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface DailyMetrics extends Changes {
|
|
122
|
+
steps(range: TimeRange): Promise<readonly StepCount[]>;
|
|
123
|
+
log(entries: readonly StepEntry[]): Promise<readonly string[]>;
|
|
124
|
+
delete(id: string): Promise<void>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface SleepMetrics extends Changes {
|
|
128
|
+
segments(range: TimeRange): Promise<readonly SleepSegment[]>;
|
|
129
|
+
log(entries: readonly SleepEntry[]): Promise<readonly string[]>;
|
|
130
|
+
delete(id: string): Promise<void>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface ActivityMetrics extends Changes {
|
|
134
|
+
workouts(range: TimeRange): Promise<readonly Workout[]>;
|
|
135
|
+
log(entries: readonly WorkoutEntry[]): Promise<readonly string[]>;
|
|
136
|
+
delete(id: string): Promise<void>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Write-only on both platforms. */
|
|
140
|
+
export interface NutritionMetrics {
|
|
141
|
+
log(entries: readonly NutritionEntry[]): Promise<readonly string[]>;
|
|
142
|
+
delete(id: string): Promise<void>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** iOS only. */
|
|
146
|
+
export interface PlannedWorkouts {
|
|
147
|
+
planned(): Promise<readonly PlannedWorkout[]>;
|
|
148
|
+
markComplete(id: string, at?: Date): Promise<void>;
|
|
149
|
+
delete(id: string): Promise<void>;
|
|
150
|
+
/** Whether plans can be put on the person's Apple Watch. Never prompts. */
|
|
151
|
+
schedulingStatus(): Promise<WorkoutSchedulingStatus>;
|
|
152
|
+
/**
|
|
153
|
+
* Shows WorkoutKit's sheet if the person has not been asked. Once authorized, every sync run
|
|
154
|
+
* keeps the Watch schedule in step with Terra; nothing else needs calling.
|
|
155
|
+
*/
|
|
156
|
+
requestScheduling(): Promise<WorkoutSchedulingStatus>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export class Terra {
|
|
160
|
+
readonly platform: Platform;
|
|
161
|
+
readonly permissions: Permissions;
|
|
162
|
+
readonly diagnostics: Diagnostics;
|
|
163
|
+
readonly background: Background;
|
|
164
|
+
readonly body: BodyMetrics;
|
|
165
|
+
readonly daily: DailyMetrics;
|
|
166
|
+
readonly sleep: SleepMetrics;
|
|
167
|
+
readonly activity: ActivityMetrics;
|
|
168
|
+
readonly nutrition: NutritionMetrics;
|
|
169
|
+
/** `undefined` on Android, which has no planned workouts. */
|
|
170
|
+
readonly plannedWorkouts: PlannedWorkouts | undefined;
|
|
171
|
+
|
|
172
|
+
private readonly ops: GeneratedClient;
|
|
173
|
+
private readonly streams: StreamHub;
|
|
174
|
+
/** Settles once the token provider, when there is one, can be asked. */
|
|
175
|
+
private readonly tokensReady: Promise<void>;
|
|
176
|
+
|
|
177
|
+
constructor(native: NativeBridge, platform: Platform, options: TerraOptions = {}) {
|
|
178
|
+
this.platform = platform;
|
|
179
|
+
const invoker: Invoker = native;
|
|
180
|
+
const ops = new GeneratedClient(invoker);
|
|
181
|
+
this.ops = ops;
|
|
182
|
+
this.streams = new StreamHub(native);
|
|
183
|
+
this.tokensReady = options.tokenProvider === undefined
|
|
184
|
+
? Promise.resolve()
|
|
185
|
+
: this.answerTokens(options.tokenProvider, options.tokenTimeoutMs ?? 30_000);
|
|
186
|
+
// The runtime can report an unhandled rejection before a later call awaits this promise.
|
|
187
|
+
this.tokensReady.catch(() => undefined);
|
|
188
|
+
|
|
189
|
+
const on = (op: OpId) => availableOn[op].includes(platform);
|
|
190
|
+
const stream = <S extends StreamId>(id: S) =>
|
|
191
|
+
(listener: StreamListener<StreamElements[S]>, onError?: (error: TerraError) => void) =>
|
|
192
|
+
this.streams.subscribe(id, listener, onError);
|
|
193
|
+
|
|
194
|
+
this.permissions = {
|
|
195
|
+
request: async (access, source) => {
|
|
196
|
+
const grant = await ops.permissionsRequest({
|
|
197
|
+
read: access.read ?? [],
|
|
198
|
+
write: access.write ?? [],
|
|
199
|
+
workouts: access.workouts ?? ([] as readonly WorkoutScope[]),
|
|
200
|
+
source,
|
|
201
|
+
});
|
|
202
|
+
return grant.granted;
|
|
203
|
+
},
|
|
204
|
+
status: async (category, source) => (await ops.permissionsStatus({ category, source })).status,
|
|
205
|
+
writeStatus: async (category, source) => (await ops.permissionsWriteStatus({ category, source })).status,
|
|
206
|
+
requested: (source) => ops.permissionsRequested({ source }),
|
|
207
|
+
...(on("permissions.statuses") && {
|
|
208
|
+
statuses: async (source?: Source) => (await ops.permissionsStatuses({ source })).statuses,
|
|
209
|
+
}),
|
|
210
|
+
...(on("permissions.backgroundReadStatus") && {
|
|
211
|
+
backgroundReadStatus: async (source?: Source) => (await ops.permissionsBackgroundReadStatus({ source })).status,
|
|
212
|
+
}),
|
|
213
|
+
...(on("permissions.historyReadStatus") && {
|
|
214
|
+
historyReadStatus: async (source?: Source) => (await ops.permissionsHistoryReadStatus({ source })).status,
|
|
215
|
+
}),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
this.diagnostics = {
|
|
219
|
+
state: (source) => ops.diagnosticsState({ source }),
|
|
220
|
+
lastDelivery: async (source) => (await ops.diagnosticsLastDelivery({ source })).at,
|
|
221
|
+
report: (source) => ops.diagnosticsReport({ source }),
|
|
222
|
+
send: async (source) => {
|
|
223
|
+
await ops.diagnosticsSend({ source });
|
|
224
|
+
},
|
|
225
|
+
events: stream("diagnostics.events"),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
this.background = {
|
|
229
|
+
isRegistered: async () => (await ops.backgroundIsRegistered({})).value,
|
|
230
|
+
...(on("background.cancel") && { cancel: async () => (await ops.backgroundCancel({})).value }),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
this.body = {
|
|
234
|
+
heartRate: async (range) => (await ops.bodyHeartRate({ range })).samples,
|
|
235
|
+
logHeartRate: async (entries) => (await ops.bodyLogHeartRate({ entries })).ids,
|
|
236
|
+
log: async (entries) => (await ops.bodyLog({ entries })).ids,
|
|
237
|
+
delete: async (id) => {
|
|
238
|
+
await ops.bodyDelete({ id });
|
|
239
|
+
},
|
|
240
|
+
changes: stream("body.changes"),
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
this.daily = {
|
|
244
|
+
steps: async (range) => (await ops.dailySteps({ range })).samples,
|
|
245
|
+
log: async (entries) => (await ops.dailyLog({ entries })).ids,
|
|
246
|
+
delete: async (id) => {
|
|
247
|
+
await ops.dailyDelete({ id });
|
|
248
|
+
},
|
|
249
|
+
changes: stream("daily.changes"),
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
this.sleep = {
|
|
253
|
+
segments: async (range) => (await ops.sleepSegments({ range })).samples,
|
|
254
|
+
log: async (entries) => (await ops.sleepLog({ entries })).ids,
|
|
255
|
+
delete: async (id) => {
|
|
256
|
+
await ops.sleepDelete({ id });
|
|
257
|
+
},
|
|
258
|
+
changes: stream("sleep.changes"),
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
this.activity = {
|
|
262
|
+
workouts: async (range) => (await ops.activityWorkouts({ range })).samples,
|
|
263
|
+
log: async (entries) => (await ops.activityLog({ entries })).ids,
|
|
264
|
+
delete: async (id) => {
|
|
265
|
+
await ops.activityDelete({ id });
|
|
266
|
+
},
|
|
267
|
+
changes: stream("activity.changes"),
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
this.nutrition = {
|
|
271
|
+
log: async (entries) => (await ops.nutritionLog({ entries })).ids,
|
|
272
|
+
delete: async (id) => {
|
|
273
|
+
await ops.nutritionDelete({ id });
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
this.plannedWorkouts = on("plannedWorkouts.planned")
|
|
278
|
+
? {
|
|
279
|
+
planned: async () => (await ops.plannedWorkoutsPlanned({})).workouts,
|
|
280
|
+
markComplete: async (id, at) => {
|
|
281
|
+
await ops.plannedWorkoutsMarkComplete({ id, at });
|
|
282
|
+
},
|
|
283
|
+
delete: async (id) => {
|
|
284
|
+
await ops.plannedWorkoutsDelete({ id });
|
|
285
|
+
},
|
|
286
|
+
schedulingStatus: async () => (await ops.plannedWorkoutsSchedulingStatus({})).status,
|
|
287
|
+
requestScheduling: async () => (await ops.plannedWorkoutsRequestScheduling({})).status,
|
|
288
|
+
}
|
|
289
|
+
: undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The native client this runtime is attached to. Rejects with `bridge.not_bootstrapped`, carrying
|
|
294
|
+
* the exact line to add in `details.line`, when the app's native launch never set Terra up.
|
|
295
|
+
*/
|
|
296
|
+
attach(): Promise<BootstrapState> {
|
|
297
|
+
return this.ops.clientAttach({});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Links `source` for the person `endUser` names, or restores the link already made for them.
|
|
302
|
+
*
|
|
303
|
+
* Every connect names its person: a second account on the same phone must not be handed the
|
|
304
|
+
* first one's link. With `token` the grant in hand is spent (a QR code, a deep link); without it
|
|
305
|
+
* the `tokenProvider` is asked, and only when a new link is actually needed.
|
|
306
|
+
*/
|
|
307
|
+
async connect(source: Source, endUser: EndUser, options: { readonly token?: string } = {}): Promise<Connection> {
|
|
308
|
+
await this.tokensReady;
|
|
309
|
+
return this.ops.clientConnect({ source, endUser, token: options.token });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async connections(): Promise<readonly Connection[]> {
|
|
313
|
+
return (await this.ops.clientConnections({})).connections;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async disconnect(source?: Source): Promise<void> {
|
|
317
|
+
await this.ops.clientDisconnect({ source });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Answers the native broker's token requests with `provider`, for the lifetime of this runtime. */
|
|
321
|
+
private async answerTokens(provider: TokenProvider, timeoutMs: number): Promise<void> {
|
|
322
|
+
await this.streams.open("bridge.tokenRequest", ({ requestId }) => {
|
|
323
|
+
void (async () => {
|
|
324
|
+
let token: string;
|
|
325
|
+
try {
|
|
326
|
+
token = await provider();
|
|
327
|
+
} catch (failed) {
|
|
328
|
+
const message = failed instanceof Error ? failed.message : String(failed);
|
|
329
|
+
await this.ops.tokenReject({ requestId, message }).catch(() => undefined);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
await this.ops.tokenResolve({ requestId, token }).catch(() => undefined);
|
|
333
|
+
})();
|
|
334
|
+
});
|
|
335
|
+
await this.ops.tokenSetProviderPresent({ present: true, timeoutMs });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
class StreamHub {
|
|
340
|
+
private readonly handlers = new Map<string, (message: string) => void>();
|
|
341
|
+
private readonly early = new Map<string, string[]>();
|
|
342
|
+
private pending = 0;
|
|
343
|
+
private listening: { remove(): void } | undefined;
|
|
344
|
+
|
|
345
|
+
constructor(private readonly native: NativeBridge) {}
|
|
346
|
+
|
|
347
|
+
/** Subscribes and answers once the native stream is open. */
|
|
348
|
+
async open<S extends StreamId>(
|
|
349
|
+
stream: S,
|
|
350
|
+
listener: StreamListener<StreamElements[S]>,
|
|
351
|
+
onError?: (error: TerraError) => void,
|
|
352
|
+
): Promise<string> {
|
|
353
|
+
this.listen();
|
|
354
|
+
this.pending += 1;
|
|
355
|
+
let id: string;
|
|
356
|
+
try {
|
|
357
|
+
id = await this.native.subscribe(stream, "{}");
|
|
358
|
+
} catch (thrown) {
|
|
359
|
+
this.settled();
|
|
360
|
+
throw TerraError.from(thrown);
|
|
361
|
+
}
|
|
362
|
+
const handle = (message: string) => {
|
|
363
|
+
const delivery = decodeStreamDelivery(stream, JSON.parse(message));
|
|
364
|
+
switch (delivery.kind) {
|
|
365
|
+
case "data":
|
|
366
|
+
listener(delivery.element);
|
|
367
|
+
return;
|
|
368
|
+
case "error":
|
|
369
|
+
this.ended(id);
|
|
370
|
+
onError?.(new TerraError(delivery.error));
|
|
371
|
+
return;
|
|
372
|
+
case "complete":
|
|
373
|
+
this.ended(id);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
this.handlers.set(id, handle);
|
|
378
|
+
const held = this.early.get(id);
|
|
379
|
+
this.early.delete(id);
|
|
380
|
+
this.settled();
|
|
381
|
+
held?.forEach(handle);
|
|
382
|
+
return id;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private settled(): void {
|
|
386
|
+
this.pending -= 1;
|
|
387
|
+
if (this.pending === 0) this.early.clear();
|
|
388
|
+
this.releaseIfIdle();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private ended(id: string): void {
|
|
392
|
+
this.handlers.delete(id);
|
|
393
|
+
this.releaseIfIdle();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private releaseIfIdle(): void {
|
|
397
|
+
if (this.handlers.size > 0 || this.pending > 0) return;
|
|
398
|
+
this.listening?.remove();
|
|
399
|
+
this.listening = undefined;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Subscribes without waiting; the returned function cancels whether or not the stream has opened yet. */
|
|
403
|
+
subscribe<S extends StreamId>(
|
|
404
|
+
stream: S,
|
|
405
|
+
listener: StreamListener<StreamElements[S]>,
|
|
406
|
+
onError?: (error: TerraError) => void,
|
|
407
|
+
): Unsubscribe {
|
|
408
|
+
let cancelled = false;
|
|
409
|
+
let opened: string | undefined;
|
|
410
|
+
this.open(stream, (element) => {
|
|
411
|
+
if (!cancelled) listener(element);
|
|
412
|
+
}, (error) => {
|
|
413
|
+
if (!cancelled) onError?.(error);
|
|
414
|
+
}).then(
|
|
415
|
+
(id) => {
|
|
416
|
+
opened = id;
|
|
417
|
+
if (cancelled) this.close(id);
|
|
418
|
+
},
|
|
419
|
+
(error: TerraError) => {
|
|
420
|
+
if (!cancelled) onError?.(error);
|
|
421
|
+
},
|
|
422
|
+
);
|
|
423
|
+
return () => {
|
|
424
|
+
if (cancelled) return;
|
|
425
|
+
cancelled = true;
|
|
426
|
+
if (opened !== undefined) this.close(opened);
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private close(id: string): void {
|
|
431
|
+
this.handlers.delete(id);
|
|
432
|
+
this.early.delete(id);
|
|
433
|
+
this.native.unsubscribe(id);
|
|
434
|
+
this.releaseIfIdle();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private listen(): void {
|
|
438
|
+
if (this.listening !== undefined) return;
|
|
439
|
+
this.listening = this.native.onMessage(({ subscriptionId, message }) => {
|
|
440
|
+
const handler = this.handlers.get(subscriptionId);
|
|
441
|
+
if (handler !== undefined) {
|
|
442
|
+
handler(message);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (this.pending === 0) return;
|
|
446
|
+
const held = this.early.get(subscriptionId) ?? [];
|
|
447
|
+
if (held.length < EARLY_LIMIT || isTerminal(message)) held.push(message);
|
|
448
|
+
this.early.set(subscriptionId, held);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const EARLY_LIMIT = 64;
|
|
454
|
+
|
|
455
|
+
function isTerminal(message: string): boolean {
|
|
456
|
+
try {
|
|
457
|
+
const kind = (JSON.parse(message) as { kind?: unknown }).kind;
|
|
458
|
+
return kind === "error" || kind === "complete";
|
|
459
|
+
} catch {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error every rejected op carries, hydrated from the bridge's JSON.
|
|
3
|
+
*
|
|
4
|
+
* Hand-written, and one of only two files here that are: the shape is fixed, while the *vocabulary*
|
|
5
|
+
* it carries is generated. `code` is a `TerraErrorCode`, so a code renamed natively stops a
|
|
6
|
+
* `switch` over it compiling rather than silently never matching.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { decodeTerraErrorPayload } from "./generated/codecs";
|
|
10
|
+
import { retryableByCode, type TerraErrorCode } from "./generated/errors";
|
|
11
|
+
import type { Source, TerraErrorPayload } from "./generated/types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The JSON a native rejection carries in its userInfo / message: the schema's own payload, except
|
|
15
|
+
* that `retryable` may be left out, in which case the per-code table answers.
|
|
16
|
+
*/
|
|
17
|
+
export type TerraErrorJSON = Omit<TerraErrorPayload, "retryable"> & { readonly retryable?: boolean };
|
|
18
|
+
|
|
19
|
+
export class TerraError extends Error {
|
|
20
|
+
readonly code: TerraErrorCode;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the same call could succeed if repeated.
|
|
23
|
+
*
|
|
24
|
+
* Read from the rejection rather than from `retryableByCode`, because one code decides per
|
|
25
|
+
* instance: `network.server_error` is retryable for a 503 and not for a 400. The table is the
|
|
26
|
+
* fallback for a rejection that did not carry the flag at all.
|
|
27
|
+
*/
|
|
28
|
+
readonly retryable: boolean;
|
|
29
|
+
readonly source: Source | undefined;
|
|
30
|
+
readonly details: Record<string, unknown> | undefined;
|
|
31
|
+
|
|
32
|
+
constructor(json: TerraErrorJSON, options?: { readonly cause?: unknown }) {
|
|
33
|
+
super(json.message, options);
|
|
34
|
+
this.name = "TerraError";
|
|
35
|
+
const known = isKnownCode(json.code);
|
|
36
|
+
this.code = known ? json.code : "platform";
|
|
37
|
+
this.retryable = known ? (json.retryable ?? fallbackRetryable(json.code)) : false;
|
|
38
|
+
this.source = json.source;
|
|
39
|
+
this.details = known ? json.details : { ...json.details, unrecognisedCode: json.code };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Rebuilds a `TerraError` from whatever the native module rejected with.
|
|
44
|
+
*
|
|
45
|
+
* Anything unrecognisable becomes `platform` rather than being rethrown as-is: a caller that has
|
|
46
|
+
* to handle both `TerraError` and an arbitrary `Error` ends up handling neither properly, and the
|
|
47
|
+
* original is kept as `cause` and its text in `message`.
|
|
48
|
+
*/
|
|
49
|
+
static from(thrown: unknown): TerraError {
|
|
50
|
+
if (thrown instanceof TerraError) return thrown;
|
|
51
|
+
const json = readJSON(thrown);
|
|
52
|
+
if (json !== undefined) return new TerraError(json, { cause: thrown });
|
|
53
|
+
return new TerraError({ code: "platform", retryable: false, message: messageOf(thrown) }, { cause: thrown });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A response the bridge resolved that this build cannot read: the native side and JS disagree. */
|
|
57
|
+
static invalidPayload(op: string, thrown: unknown): TerraError {
|
|
58
|
+
return new TerraError(
|
|
59
|
+
{ code: "bridge.invalid_payload", message: `${op}: ${messageOf(thrown)}`, details: { op } },
|
|
60
|
+
{ cause: thrown },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isKnownCode(code: string): code is TerraErrorCode {
|
|
66
|
+
return Object.prototype.hasOwnProperty.call(retryableByCode, code);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function messageOf(thrown: unknown): string {
|
|
70
|
+
if (thrown instanceof Error) return thrown.message;
|
|
71
|
+
if (isObject(thrown) && typeof thrown.message === "string") return thrown.message;
|
|
72
|
+
return String(thrown);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fallbackRetryable(code: TerraErrorCode): boolean {
|
|
76
|
+
return retryableByCode[code] === true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A JSON string preserves payload types across `NSError` and `WritableMap`, unlike a nested map. */
|
|
80
|
+
function readJSON(thrown: unknown): TerraErrorJSON | undefined {
|
|
81
|
+
if (typeof thrown !== "object" || thrown === null) return undefined;
|
|
82
|
+
const candidate = thrown as Record<string, unknown>;
|
|
83
|
+
const userInfo = candidate.userInfo;
|
|
84
|
+
const encoded = isObject(userInfo) ? userInfo.payload : undefined;
|
|
85
|
+
return (typeof encoded === "string" ? decode(parse(encoded)) : undefined) ?? decode(userInfo) ?? decode(candidate);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parse(encoded: string): unknown {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(encoded);
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The generated decoder does the checking: a string code, a boolean flag, a known source. */
|
|
97
|
+
function decode(raw: unknown): TerraErrorJSON | undefined {
|
|
98
|
+
if (!isObject(raw) || typeof raw.code !== "string") return undefined;
|
|
99
|
+
try {
|
|
100
|
+
// Read field by field: an Error's `message` is not enumerable, so spreading one drops it.
|
|
101
|
+
const { retryable, ...rest } = decodeTerraErrorPayload({
|
|
102
|
+
code: raw.code,
|
|
103
|
+
retryable: raw.retryable ?? false,
|
|
104
|
+
message: raw.message,
|
|
105
|
+
source: raw.source,
|
|
106
|
+
details: raw.details,
|
|
107
|
+
});
|
|
108
|
+
return raw.retryable === undefined ? rest : { ...rest, retryable };
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
115
|
+
return typeof value === "object" && value !== null;
|
|
116
|
+
}
|