@twinfinity/twin 0.0.0-feature.31596

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 (245) hide show
  1. package/README.md +277 -0
  2. package/dist/BuildInfo.d.ts +25 -0
  3. package/dist/BuildInfo.d.ts.map +1 -0
  4. package/dist/BuildInfo.js +15 -0
  5. package/dist/BuildInfo.js.map +1 -0
  6. package/dist/TwinClient.d.ts +823 -0
  7. package/dist/TwinClient.d.ts.map +1 -0
  8. package/dist/TwinClient.js +1487 -0
  9. package/dist/TwinClient.js.map +1 -0
  10. package/dist/errors.d.ts +289 -0
  11. package/dist/errors.d.ts.map +1 -0
  12. package/dist/errors.js +357 -0
  13. package/dist/errors.js.map +1 -0
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +4 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/tsconfig.tsbuildinfo +1 -0
  19. package/dist/types/Twin.d.ts +7 -0
  20. package/dist/types/Twin.d.ts.map +1 -0
  21. package/dist/types/Twin.js +6 -0
  22. package/dist/types/Twin.js.map +1 -0
  23. package/dist/types/TwinGeometryInfo.d.ts +7 -0
  24. package/dist/types/TwinGeometryInfo.d.ts.map +1 -0
  25. package/dist/types/TwinGeometryInfo.js +2 -0
  26. package/dist/types/TwinGeometryInfo.js.map +1 -0
  27. package/dist/types/asset-api.d.ts +66 -0
  28. package/dist/types/asset-api.d.ts.map +1 -0
  29. package/dist/types/asset-api.js +2 -0
  30. package/dist/types/asset-api.js.map +1 -0
  31. package/dist/types/asset-attachment-api.d.ts +51 -0
  32. package/dist/types/asset-attachment-api.d.ts.map +1 -0
  33. package/dist/types/asset-attachment-api.js +8 -0
  34. package/dist/types/asset-attachment-api.js.map +1 -0
  35. package/dist/types/asset-batch-api.d.ts +73 -0
  36. package/dist/types/asset-batch-api.d.ts.map +1 -0
  37. package/dist/types/asset-batch-api.js +2 -0
  38. package/dist/types/asset-batch-api.js.map +1 -0
  39. package/dist/types/asset-types-api.d.ts +76 -0
  40. package/dist/types/asset-types-api.d.ts.map +1 -0
  41. package/dist/types/asset-types-api.js +5 -0
  42. package/dist/types/asset-types-api.js.map +1 -0
  43. package/dist/types/common.d.ts +10 -0
  44. package/dist/types/common.d.ts.map +1 -0
  45. package/dist/types/common.js +2 -0
  46. package/dist/types/common.js.map +1 -0
  47. package/dist/types/generated.d.ts +458 -0
  48. package/dist/types/generated.d.ts.map +1 -0
  49. package/dist/types/generated.js +2 -0
  50. package/dist/types/generated.js.map +1 -0
  51. package/dist/types/index.d.ts +10 -0
  52. package/dist/types/index.d.ts.map +1 -0
  53. package/dist/types/index.js +10 -0
  54. package/dist/types/index.js.map +1 -0
  55. package/dist/types/twin-api.d.ts +141 -0
  56. package/dist/types/twin-api.d.ts.map +1 -0
  57. package/dist/types/twin-api.js +2 -0
  58. package/dist/types/twin-api.js.map +1 -0
  59. package/dist/types/twin-file-api.d.ts +61 -0
  60. package/dist/types/twin-file-api.d.ts.map +1 -0
  61. package/dist/types/twin-file-api.js +2 -0
  62. package/dist/types/twin-file-api.js.map +1 -0
  63. package/dist/types/twin-permissions-api.d.ts +240 -0
  64. package/dist/types/twin-permissions-api.d.ts.map +1 -0
  65. package/dist/types/twin-permissions-api.js +2 -0
  66. package/dist/types/twin-permissions-api.js.map +1 -0
  67. package/docs/.nojekyll +1 -0
  68. package/docs/assets/hierarchy.js +1 -0
  69. package/docs/assets/highlight.css +99 -0
  70. package/docs/assets/icons.js +18 -0
  71. package/docs/assets/icons.svg +1 -0
  72. package/docs/assets/main.js +60 -0
  73. package/docs/assets/navigation.js +1 -0
  74. package/docs/assets/search.js +1 -0
  75. package/docs/assets/style.css +1633 -0
  76. package/docs/classes/AssetAttachmentConflictError.html +3 -0
  77. package/docs/classes/AssetAttachmentNotFoundError.html +2 -0
  78. package/docs/classes/AssetBatchError.html +3 -0
  79. package/docs/classes/AssetCatalogTooLargeError.html +16 -0
  80. package/docs/classes/AssetCategoryConflictError.html +3 -0
  81. package/docs/classes/AssetCategoryForbiddenError.html +2 -0
  82. package/docs/classes/AssetCategoryNotFoundError.html +2 -0
  83. package/docs/classes/AssetCategoryValidationError.html +3 -0
  84. package/docs/classes/AssetConflictError.html +3 -0
  85. package/docs/classes/AssetForbiddenError.html +2 -0
  86. package/docs/classes/AssetNotFoundError.html +2 -0
  87. package/docs/classes/AssetPermissionsConflictError.html +16 -0
  88. package/docs/classes/AssetTypeConflictError.html +3 -0
  89. package/docs/classes/AssetTypeForbiddenError.html +2 -0
  90. package/docs/classes/AssetTypeNotFoundError.html +2 -0
  91. package/docs/classes/AssetTypeValidationError.html +3 -0
  92. package/docs/classes/AssetValidationError.html +3 -0
  93. package/docs/classes/ProcessorNotFoundError.html +2 -0
  94. package/docs/classes/TwinClient.html +409 -0
  95. package/docs/classes/TwinConflictError.html +3 -0
  96. package/docs/classes/TwinFileConflictError.html +3 -0
  97. package/docs/classes/TwinFileNotFoundError.html +2 -0
  98. package/docs/classes/TwinFileValidationError.html +3 -0
  99. package/docs/classes/TwinNotFoundError.html +4 -0
  100. package/docs/classes/TwinPermissionsConflictError.html +19 -0
  101. package/docs/classes/TwinPermissionsForbiddenError.html +12 -0
  102. package/docs/classes/TwinPrincipalNotFoundError.html +5 -0
  103. package/docs/classes/TwinTemporarilyUnavailableError.html +10 -0
  104. package/docs/classes/TwinUnauthenticatedError.html +6 -0
  105. package/docs/classes/TwinValidationError.html +3 -0
  106. package/docs/functions/isAssetCatalogTooLargeErrorBody.html +6 -0
  107. package/docs/functions/readErrorBody.html +10 -0
  108. package/docs/hierarchy.html +1 -0
  109. package/docs/index.html +168 -0
  110. package/docs/interfaces/ApiCreateUploadSessionResponse.html +7 -0
  111. package/docs/interfaces/AssetAttachmentPut.html +6 -0
  112. package/docs/interfaces/AssetBatchDeleteOperation.html +4 -0
  113. package/docs/interfaces/AssetBatchGetRequest.html +2 -0
  114. package/docs/interfaces/AssetBatchGetResponse.html +3 -0
  115. package/docs/interfaces/AssetBatchRequest.html +2 -0
  116. package/docs/interfaces/AssetBatchResponse.html +3 -0
  117. package/docs/interfaces/AssetBatchRestoreOperation.html +4 -0
  118. package/docs/interfaces/AssetBatchUpsertItem.html +12 -0
  119. package/docs/interfaces/AssetBatchUpsertOperation.html +4 -0
  120. package/docs/interfaces/AssetCatalogCategory.html +7 -0
  121. package/docs/interfaces/AssetCatalogResponse.html +6 -0
  122. package/docs/interfaces/AssetCatalogTooLargeErrorBody.html +15 -0
  123. package/docs/interfaces/AssetCategoryDelete.html +2 -0
  124. package/docs/interfaces/AssetCategoryGet.html +14 -0
  125. package/docs/interfaces/AssetCategoryPut.html +7 -0
  126. package/docs/interfaces/AssetGet.html +18 -0
  127. package/docs/interfaces/AssetGroupGet.html +9 -0
  128. package/docs/interfaces/AssetGroupsGet.html +3 -0
  129. package/docs/interfaces/AssetListItem.html +14 -0
  130. package/docs/interfaces/AssetPermissionsConflictErrorBody.html +12 -0
  131. package/docs/interfaces/AssetPermissionsPut.html +9 -0
  132. package/docs/interfaces/AssetPosition.html +4 -0
  133. package/docs/interfaces/AssetPost.html +12 -0
  134. package/docs/interfaces/AssetPrincipalPermissionsGet.html +23 -0
  135. package/docs/interfaces/AssetPut.html +12 -0
  136. package/docs/interfaces/AssetTypeDelete.html +2 -0
  137. package/docs/interfaces/AssetTypeGet.html +16 -0
  138. package/docs/interfaces/AssetTypePut.html +9 -0
  139. package/docs/interfaces/AttachmentGet.html +20 -0
  140. package/docs/interfaces/AttachmentPost.html +4 -0
  141. package/docs/interfaces/BatchItemError.html +4 -0
  142. package/docs/interfaces/BatchItemResult.html +6 -0
  143. package/docs/interfaces/BatchSummary.html +4 -0
  144. package/docs/interfaces/Coordinates.html +6 -0
  145. package/docs/interfaces/Diagnostics.html +4 -0
  146. package/docs/interfaces/DiagnosticsSummary.html +4 -0
  147. package/docs/interfaces/ElementQuantities.html +3 -0
  148. package/docs/interfaces/EntityCollection.html +15 -0
  149. package/docs/interfaces/EntityRepresentation.html +12 -0
  150. package/docs/interfaces/Facility.html +22 -0
  151. package/docs/interfaces/FacilityPart.html +18 -0
  152. package/docs/interfaces/FilePatch.html +7 -0
  153. package/docs/interfaces/FilePatchSet.html +5 -0
  154. package/docs/interfaces/GenericDiagnosticEntry.html +4 -0
  155. package/docs/interfaces/ITwinClient.html +404 -0
  156. package/docs/interfaces/IfcDiagnosticEntry.html +5 -0
  157. package/docs/interfaces/IfcDiagnosticNode.html +6 -0
  158. package/docs/interfaces/IfcDiagnosticNodeData.html +6 -0
  159. package/docs/interfaces/InheritedPermissions.html +8 -0
  160. package/docs/interfaces/ListTwinItem.html +30 -0
  161. package/docs/interfaces/Location.html +13 -0
  162. package/docs/interfaces/MyPermissions.html +7 -0
  163. package/docs/interfaces/MyTwinPermissions.html +5 -0
  164. package/docs/interfaces/PaginatedResponse.html +3 -0
  165. package/docs/interfaces/Pagination.html +4 -0
  166. package/docs/interfaces/Property.html +4 -0
  167. package/docs/interfaces/PropertySet.html +6 -0
  168. package/docs/interfaces/SimpleQuantity.html +6 -0
  169. package/docs/interfaces/SinglePropertyValue.html +5 -0
  170. package/docs/interfaces/Site.html +27 -0
  171. package/docs/interfaces/Space.html +16 -0
  172. package/docs/interfaces/TwinComputedPropertySetsResponse.html +4 -0
  173. package/docs/interfaces/TwinContentResponse.html +6 -0
  174. package/docs/interfaces/TwinDelete.html +2 -0
  175. package/docs/interfaces/TwinDiagnosticEntry.html +5 -0
  176. package/docs/interfaces/TwinDiagnosticNode.html +4 -0
  177. package/docs/interfaces/TwinDiagnosticNodeData.html +9 -0
  178. package/docs/interfaces/TwinDiagnosticsResponse.html +5 -0
  179. package/docs/interfaces/TwinErrorBody.html +4 -0
  180. package/docs/interfaces/TwinFileDelete.html +2 -0
  181. package/docs/interfaces/TwinFileGet.html +15 -0
  182. package/docs/interfaces/TwinFileListOptions.html +6 -0
  183. package/docs/interfaces/TwinFilePut.html +8 -0
  184. package/docs/interfaces/TwinFileReference.html +4 -0
  185. package/docs/interfaces/TwinFileRestore.html +2 -0
  186. package/docs/interfaces/TwinFileSource.html +4 -0
  187. package/docs/interfaces/TwinFileTransform.html +11 -0
  188. package/docs/interfaces/TwinFileVersionListOptions.html +4 -0
  189. package/docs/interfaces/TwinGet.html +48 -0
  190. package/docs/interfaces/TwinGroupGet.html +10 -0
  191. package/docs/interfaces/TwinGroupsGet.html +3 -0
  192. package/docs/interfaces/TwinPermissionsConflictErrorBody.html +8 -0
  193. package/docs/interfaces/TwinPermissionsPut.html +11 -0
  194. package/docs/interfaces/TwinPrincipalPermissionsGet.html +34 -0
  195. package/docs/interfaces/TwinProcessorEvent.html +7 -0
  196. package/docs/interfaces/TwinProcessorResponse.html +5 -0
  197. package/docs/interfaces/TwinPut.html +21 -0
  198. package/docs/interfaces/TwinReference.html +4 -0
  199. package/docs/interfaces/TwinSpecification.html +18 -0
  200. package/docs/interfaces/TwinVersionContentResponse.html +7 -0
  201. package/docs/interfaces/UploadStatusGet.html +3 -0
  202. package/docs/media/twin-management.md +399 -0
  203. package/docs/modules.html +1 -0
  204. package/docs/types/AssetBatchGetStatus.html +1 -0
  205. package/docs/types/AssetBatchOperation.html +1 -0
  206. package/docs/types/AssetBatchOperationStatus.html +1 -0
  207. package/docs/types/DiagnosticCategory.html +2 -0
  208. package/docs/types/DiagnosticLevel.html +2 -0
  209. package/docs/types/EpsgType.html +1 -0
  210. package/docs/types/FacilityClass.html +3 -0
  211. package/docs/types/FacilityPartClass.html +3 -0
  212. package/docs/types/IfcDiagnosticCode.html +1 -0
  213. package/docs/types/Op.html +2 -0
  214. package/docs/types/SinglePropertyValueType.html +3 -0
  215. package/docs/types/SingleQuantityValueType.html +3 -0
  216. package/docs/types/SiteType.html +3 -0
  217. package/docs/types/SpaceType.html +2 -0
  218. package/docs/types/TwinBoundCapabilityName.html +8 -0
  219. package/docs/types/TwinCapability.html +12 -0
  220. package/docs/types/TwinCapabilityName.html +8 -0
  221. package/docs/types/TwinDiagnosticCode.html +1 -0
  222. package/docs/types/TwinErrorCause.html +8 -0
  223. package/docs/types/TwinErrorItemKind.html +4 -0
  224. package/docs/types/TwinPermissionsConflictCause.html +6 -0
  225. package/docs/types/TwinScopedCapability.html +9 -0
  226. package/docs/types/TwinScopedCapabilityName.html +8 -0
  227. package/docs/types/UploadStatus.html +1 -0
  228. package/package.json +52 -0
  229. package/src/BuildInfo.ts +41 -0
  230. package/src/TwinClient.ts +3113 -0
  231. package/src/errors.ts +567 -0
  232. package/src/index.ts +3 -0
  233. package/src/tsconfig.json +13 -0
  234. package/src/types/Twin.ts +7 -0
  235. package/src/types/TwinGeometryInfo.ts +7 -0
  236. package/src/types/asset-api.ts +69 -0
  237. package/src/types/asset-attachment-api.ts +56 -0
  238. package/src/types/asset-batch-api.ts +90 -0
  239. package/src/types/asset-types-api.ts +86 -0
  240. package/src/types/common.ts +10 -0
  241. package/src/types/generated.ts +655 -0
  242. package/src/types/index.ts +9 -0
  243. package/src/types/twin-api.ts +153 -0
  244. package/src/types/twin-file-api.ts +67 -0
  245. package/src/types/twin-permissions-api.ts +257 -0
@@ -0,0 +1,3113 @@
1
+ import { TwinfinityHttpClient, HttpMethod } from '@twinfinity/authentication';
2
+ import {
3
+ readT8ClientModelWithFactories,
4
+ readT8ClientModelWithGeometry,
5
+ readT8GIModelWithFactories,
6
+ T8ClientEntityTypeDTO,
7
+ T8ClientModel,
8
+ T8ClientModelDTO,
9
+ T8ClientProductConnectionsDTO,
10
+ T8ClientProductDTO,
11
+ T8ClientProjectConnectionsDTO,
12
+ T8ClientProjectDTO,
13
+ T8ClientUnitDTO,
14
+ T8EntityType,
15
+ T8GIMeshDescriptor,
16
+ T8GIMeshDescriptorDTO,
17
+ T8GIStyle,
18
+ T8GIStyleDTO,
19
+ T8GIRegion,
20
+ T8GIRegionDTO,
21
+ T8Product,
22
+ T8ProductWithGeometry,
23
+ T8Project,
24
+ T8Unit
25
+ } from '@twinfinity/t8-client-model';
26
+ import type { T8ProjectWithGeometry } from '@twinfinity/t8-client-model';
27
+ import type { T8GIModelGeneric } from '@twinfinity/t8-client-model';
28
+ import type {
29
+ PaginatedResponse,
30
+ ListTwinItem,
31
+ TwinGet,
32
+ TwinPut,
33
+ TwinVersionContentResponse,
34
+ TwinProcessorEvent,
35
+ TwinDiagnosticsResponse,
36
+ AssetListItem,
37
+ AssetGet,
38
+ AssetPost,
39
+ AssetPut,
40
+ AssetBatchOperation,
41
+ AssetBatchResponse,
42
+ AssetBatchGetResponse,
43
+ TwinFileGet,
44
+ TwinFilePut,
45
+ TwinFileDelete,
46
+ TwinFileListOptions,
47
+ TwinFileVersionListOptions,
48
+ TwinContentResponse
49
+ } from './types';
50
+ import {
51
+ TwinValidationError,
52
+ TwinConflictError,
53
+ TwinNotFoundError,
54
+ ProcessorNotFoundError,
55
+ AssetValidationError,
56
+ AssetForbiddenError,
57
+ AssetConflictError,
58
+ AssetNotFoundError,
59
+ AssetAttachmentConflictError,
60
+ AssetAttachmentNotFoundError,
61
+ AssetBatchError,
62
+ TwinFileValidationError,
63
+ TwinFileConflictError,
64
+ TwinFileNotFoundError,
65
+ AssetCategoryValidationError,
66
+ AssetCategoryConflictError,
67
+ AssetCategoryNotFoundError,
68
+ AssetCategoryForbiddenError,
69
+ AssetTypeValidationError,
70
+ AssetTypeConflictError,
71
+ AssetTypeNotFoundError,
72
+ AssetTypeForbiddenError,
73
+ AssetCatalogTooLargeError,
74
+ isAssetCatalogTooLargeErrorBody,
75
+ TwinUnauthenticatedError,
76
+ TwinPermissionsConflictError,
77
+ TwinPermissionsForbiddenError,
78
+ TwinPrincipalNotFoundError,
79
+ TwinTemporarilyUnavailableError,
80
+ AssetPermissionsConflictError,
81
+ readErrorBody
82
+ } from './errors';
83
+ import type { TwinPermissionsConflictErrorBody, AssetPermissionsConflictErrorBody } from './errors';
84
+ import type {
85
+ AssetCategoryGet,
86
+ AssetCategoryPut,
87
+ AssetCatalogResponse,
88
+ AssetTypeGet,
89
+ AssetTypePut
90
+ } from './types/asset-types-api';
91
+ import type {
92
+ AttachmentGet,
93
+ AttachmentPost,
94
+ AssetAttachmentPut,
95
+ ApiCreateUploadSessionResponse,
96
+ UploadStatusGet
97
+ } from './types/asset-attachment-api';
98
+ import type {
99
+ MyPermissions,
100
+ MyTwinPermissions,
101
+ TwinGroupGet,
102
+ TwinGroupsGet,
103
+ TwinPermissionsPut,
104
+ TwinPrincipalPermissionsGet,
105
+ AssetGroupGet,
106
+ AssetGroupsGet,
107
+ AssetPermissionsPut,
108
+ AssetPrincipalPermissionsGet
109
+ } from './types/twin-permissions-api';
110
+ import { Twin } from './types/Twin';
111
+ import { TwinGeometryInfo } from './types/TwinGeometryInfo';
112
+
113
+ export interface ITwinClient {
114
+ /**
115
+ * Rooted twins base URL — typically `<host>/sites/<container>/_ps/twins`.
116
+ * Use this when constructing related URLs (e.g. twin content) that need
117
+ * to live in the same auth scope as the metadata calls.
118
+ */
119
+ readonly baseUrl: string;
120
+
121
+ /**
122
+ * @param options - `relatedTo` filters by container ID; `includeDeleted` includes soft-deleted twins
123
+ * (returned redacted, default false); `page`/`limit` control pagination.
124
+ * @throws {TwinValidationError} On invalid query parameters (400).
125
+ */
126
+ listTwins(
127
+ options?: {
128
+ relatedTo?: string;
129
+ includeDeleted?: boolean;
130
+ page?: string;
131
+ limit?: number;
132
+ },
133
+ signal?: AbortSignal
134
+ ): Promise<PaginatedResponse<ListTwinItem>>;
135
+ /**
136
+ * @deprecated Use the options-object form: `listTwins({ relatedTo, page, limit }, signal)`.
137
+ * The positional form has no `includeDeleted` parameter; that is only reachable via the options object.
138
+ */
139
+ listTwins(
140
+ relatedTo?: string,
141
+ page?: string,
142
+ limit?: number,
143
+ signal?: AbortSignal
144
+ ): Promise<PaginatedResponse<ListTwinItem>>;
145
+
146
+ /**
147
+ * Fetches all twins by auto-paginating through {@link listTwins}. Caps at 1000 pages.
148
+ * @param options - `relatedTo` filters by container ID; `includeDeleted` includes soft-deleted twins
149
+ * (returned redacted, default false).
150
+ */
151
+ listAllTwins(
152
+ options?: { relatedTo?: string; includeDeleted?: boolean },
153
+ signal?: AbortSignal
154
+ ): Promise<ListTwinItem[]>;
155
+ /**
156
+ * @deprecated Use the options-object form: `listAllTwins({ relatedTo }, signal)`.
157
+ * The positional form has no `includeDeleted` parameter; that is only reachable via the options object.
158
+ */
159
+ listAllTwins(relatedTo?: string, signal?: AbortSignal): Promise<ListTwinItem[]>;
160
+
161
+ /**
162
+ * @param twin - Twin ID string, or `{id, version}` to fetch a specific version.
163
+ * @throws {TwinNotFoundError} When the twin or version does not exist (404).
164
+ */
165
+ getTwin(twin: string | { id: string; version: number }, signal?: AbortSignal): Promise<TwinGet>;
166
+
167
+ /**
168
+ * @throws {TwinValidationError} On invalid query parameters (400).
169
+ */
170
+ listTwinVersions(
171
+ id: string,
172
+ options?: {
173
+ includeDeleted?: boolean;
174
+ page?: string;
175
+ limit?: number;
176
+ },
177
+ signal?: AbortSignal
178
+ ): Promise<PaginatedResponse<ListTwinItem>>;
179
+
180
+ downloadTwinContent(
181
+ twinId: string,
182
+ twinVersion: number,
183
+ content: TwinContentResponse,
184
+ signal?: AbortSignal
185
+ ): Promise<ArrayBuffer>;
186
+
187
+ /**
188
+ * Downloads and deserializes the T8 client model (t8cm + t8cm-ext) using default types.
189
+ * When no version is specified, searches versions newest-first for one containing t8cm content.
190
+ *
191
+ * @param twin - Twin ID string, or `{id, version}` to load a specific version.
192
+ * @throws {Error} When no version has t8cm content.
193
+ */
194
+ loadTwin(
195
+ twin: string | { id: string; version: number },
196
+ signal?: AbortSignal
197
+ ): Promise<Twin<T8Project, T8Product<T8EntityType>, T8EntityType, T8Unit>>;
198
+
199
+ /**
200
+ * Downloads and deserializes the T8 client model using custom factory functions for domain-specific types.
201
+ * When no version is specified, searches versions newest-first for one containing t8cm content.
202
+ *
203
+ * @param twin - Twin ID string, or `{id, version}` to load a specific version.
204
+ * @throws {Error} When no version has t8cm content.
205
+ */
206
+ loadTwinWithFactories<TProject, TProduct, TEntityType, TUnit>(
207
+ twin: string | { id: string; version: number },
208
+ projectFactory: (dto: T8ClientProjectDTO<TUnit>) => TProject,
209
+ productFactory: (dto: T8ClientProductDTO<TUnit>) => TProduct,
210
+ entityTypeFactory: (dto: T8ClientEntityTypeDTO<TUnit>) => TEntityType,
211
+ unitFactory: (dto: T8ClientUnitDTO, dataModel: T8ClientModelDTO) => TUnit,
212
+ connectProject: (dto: T8ClientProjectConnectionsDTO<TProduct>, project: TProject) => void,
213
+ connectProduct: (dto: T8ClientProductConnectionsDTO<TProduct, TEntityType>, product: TProduct) => void,
214
+ signal?: AbortSignal
215
+ ): Promise<Twin<TProject, TProduct, TEntityType, TUnit>>;
216
+
217
+ /**
218
+ * Downloads and deserializes geometry info (t8gi) using default types.
219
+ * When no version is specified, searches versions newest-first for one containing t8gi content.
220
+ *
221
+ * @param twin - Twin ID string, or `{id, version}` to load a specific version.
222
+ * @throws {Error} When no version has t8gi content.
223
+ */
224
+ loadGeometryInfo(
225
+ twin: string | { id: string; version: number },
226
+ signal?: AbortSignal
227
+ ): Promise<TwinGeometryInfo<T8GIMeshDescriptor, T8GIStyle, T8GIRegion>>;
228
+
229
+ /**
230
+ * Downloads and deserializes geometry info (t8gi) using custom factory functions.
231
+ * When no version is specified, searches versions newest-first for one containing t8gi content.
232
+ *
233
+ * @param twin - Twin ID string, or `{id, version}` to load a specific version.
234
+ * @throws {Error} When no version has t8gi content.
235
+ */
236
+ loadGeometryInfoWithFactories<TMeshDescriptor, TStyle, TRegion>(
237
+ twin: string | { id: string; version: number },
238
+ meshDescriptorFactory: (dto: T8GIMeshDescriptorDTO) => TMeshDescriptor,
239
+ styleFactory: (dto: T8GIStyleDTO) => TStyle,
240
+ regionFactory: (dto: T8GIRegionDTO) => TRegion,
241
+ signal?: AbortSignal
242
+ ): Promise<TwinGeometryInfo<TMeshDescriptor, TStyle, TRegion>>;
243
+
244
+ /**
245
+ * Downloads and deserializes both the client model (t8cm) and geometry (t8gi/t8gd) into a combined result.
246
+ * When no version is specified, searches versions newest-first for one containing both t8cm and t8gi content.
247
+ *
248
+ * @param twin - Twin ID string, or `{id, version}` to load a specific version.
249
+ * @throws {Error} When no version has both t8cm and t8gi content.
250
+ */
251
+ loadTwinWithGeometry(
252
+ twin: string | { id: string; version: number },
253
+ signal?: AbortSignal
254
+ ): Promise<Twin<T8ProjectWithGeometry, T8ProductWithGeometry, T8EntityType, T8Unit>>;
255
+
256
+ /**
257
+ * Creates a new twin or updates an existing one (upsert). Provide `etag` in the twin body to update.
258
+ *
259
+ * @throws {TwinValidationError} On invalid request body (400).
260
+ * @throws {TwinConflictError} On etag mismatch (409). The error's `currentTwin` contains the server's current state.
261
+ */
262
+ createOrUpdateTwin(twinId: string, twin: TwinPut, signal?: AbortSignal): Promise<TwinGet>;
263
+
264
+ /** Alias for {@link createOrUpdateTwin}. */
265
+ updateTwin(twinId: string, twin: TwinPut, signal?: AbortSignal): Promise<TwinGet>;
266
+
267
+ /**
268
+ * @throws {TwinNotFoundError} When the twin does not exist (404).
269
+ * @throws {TwinConflictError} On etag mismatch (409). The error's `currentTwin` contains the server's current state.
270
+ */
271
+ restoreTwin(twinId: string, etag: string, signal?: AbortSignal): Promise<TwinGet>;
272
+
273
+ /**
274
+ * @throws {TwinNotFoundError} When the twin or version does not exist (404).
275
+ */
276
+ getTwinVersionContent(
277
+ twinId: string,
278
+ twinVersion: number,
279
+ signal?: AbortSignal
280
+ ): Promise<TwinVersionContentResponse>;
281
+
282
+ /**
283
+ * Returns content for the latest twin version.
284
+ * @throws {TwinNotFoundError} When the twin does not exist (404).
285
+ */
286
+ getTwinContent(twinId: string, signal?: AbortSignal): Promise<TwinVersionContentResponse>;
287
+
288
+ /** @throws {TwinNotFoundError} When the twin does not exist (404). */
289
+ getTwinDiagnostics(twinId: string, signal?: AbortSignal): Promise<TwinDiagnosticsResponse>;
290
+
291
+ /** @throws {TwinNotFoundError} When the twin or version does not exist (404). */
292
+ getTwinVersionDiagnostics(
293
+ twinId: string,
294
+ twinVersion: number,
295
+ signal?: AbortSignal
296
+ ): Promise<TwinDiagnosticsResponse>;
297
+
298
+ /**
299
+ * @throws {TwinValidationError} On invalid query parameters (400).
300
+ * @throws {TwinNotFoundError} When the twin or version does not exist (404).
301
+ */
302
+ getTwinVersionEvents(
303
+ twinId: string,
304
+ twinVersion: number,
305
+ offset?: number,
306
+ limit?: number,
307
+ signal?: AbortSignal
308
+ ): Promise<PaginatedResponse<TwinProcessorEvent>>;
309
+
310
+ /**
311
+ * @throws {TwinValidationError} On invalid query parameters (400).
312
+ * @throws {ProcessorNotFoundError} When the processor does not exist for this twin version (404).
313
+ */
314
+ getProcessorEvents(
315
+ twinId: string,
316
+ twinVersion: number,
317
+ processorId: string,
318
+ offset?: number,
319
+ limit?: number,
320
+ signal?: AbortSignal
321
+ ): Promise<PaginatedResponse<TwinProcessorEvent>>;
322
+
323
+ /**
324
+ * @throws {AssetValidationError} On invalid query parameters (400).
325
+ * @throws {AssetNotFoundError} When the twin does not exist (404).
326
+ */
327
+ listAssets(
328
+ twinId: string,
329
+ offset?: number,
330
+ limit?: number,
331
+ options?: { includeDeleted?: boolean },
332
+ signal?: AbortSignal
333
+ ): Promise<PaginatedResponse<AssetListItem>>;
334
+
335
+ /** @throws {AssetNotFoundError} When the asset does not exist (404). */
336
+ getAsset(twinId: string, assetId: string, signal?: AbortSignal): Promise<AssetGet>;
337
+
338
+ /** @throws {AssetValidationError} On invalid request body (400). The error's `errorBody` has structured detail. */
339
+ createAssets(twinId: string, assets: AssetPost[], signal?: AbortSignal): Promise<AssetGet[]>;
340
+
341
+ /**
342
+ * @throws {AssetValidationError} On invalid request body (400). The error's `errorBody` has structured detail.
343
+ * @throws {AssetConflictError} On etag mismatch (409). The error's `currentAsset` contains the server's current state.
344
+ */
345
+ updateAsset(twinId: string, assetId: string, asset: AssetPut, signal?: AbortSignal): Promise<AssetGet>;
346
+
347
+ /**
348
+ * @throws {AssetNotFoundError} When the asset does not exist (404).
349
+ * @throws {AssetConflictError} On etag mismatch (409). The error's `currentAsset` contains the server's current state.
350
+ */
351
+ deleteAsset(twinId: string, assetId: string, etag: string, signal?: AbortSignal): Promise<AssetGet>;
352
+
353
+ /**
354
+ * @throws {AssetNotFoundError} When the asset does not exist (404).
355
+ * @throws {AssetConflictError} On etag mismatch (409). The error's `currentAsset` contains the server's current state.
356
+ */
357
+ restoreAsset(twinId: string, assetId: string, etag: string, signal?: AbortSignal): Promise<AssetGet>;
358
+
359
+ /**
360
+ * Executes a batch of asset operations (upsert, delete, restore) atomically.
361
+ * All operations succeed or all are rolled back.
362
+ *
363
+ * @param operations - Array of batch operations (max 1000).
364
+ * @throws {AssetBatchError} When the batch rolled back. The error's `response` has per-item
365
+ * results with reverted statuses, and is thrown for any status the failing item carried --
366
+ * 400, 404 or 409.
367
+ * @throws {AssetValidationError} When the whole request was rejected before any item ran (400):
368
+ * an empty batch, duplicate ids, a body/URL twin mismatch, oversized metadata.
369
+ * @throws {AssetForbiddenError} On insufficient permissions (403).
370
+ */
371
+ batchAssets(twinId: string, operations: AssetBatchOperation[], signal?: AbortSignal): Promise<AssetBatchResponse>;
372
+
373
+ /**
374
+ * Retrieves multiple assets by ID in a single request.
375
+ *
376
+ * @param ids - Array of asset IDs (max 1000).
377
+ * @throws {AssetValidationError} On validation failure (400).
378
+ */
379
+ batchGetAssets(twinId: string, ids: string[], signal?: AbortSignal): Promise<AssetBatchGetResponse>;
380
+
381
+ /** Lists attachments for a single asset. */
382
+ listAssetAttachments(
383
+ twinId: string,
384
+ assetId: string,
385
+ options?: { includeDeleted?: boolean; offset?: number; limit?: number; page?: string },
386
+ signal?: AbortSignal
387
+ ): Promise<PaginatedResponse<AttachmentGet>>;
388
+
389
+ /**
390
+ * Downloads an attachment's blob as a `Response`. The caller decides
391
+ * whether to consume it as text, ArrayBuffer, or Blob.
392
+ * @throws {AssetAttachmentNotFoundError} When the attachment does not exist (404).
393
+ */
394
+ getAssetAttachmentBlob(
395
+ twinId: string,
396
+ assetId: string,
397
+ attachmentId: string,
398
+ signal?: AbortSignal
399
+ ): Promise<Response>;
400
+
401
+ /**
402
+ * Creates an upload session for a new attachment. The actual file bytes
403
+ * are uploaded out-of-band to {@link ApiCreateUploadSessionResponse.uploadUrl};
404
+ * use {@link uploadAssetAttachmentFile} for the canonical chunked-upload flow.
405
+ * The attachment row is materialized server-side when the blob lands.
406
+ * @throws {AssetAttachmentConflictError} When an attachment already exists
407
+ * for the given id (409); retries must use a new attachmentId.
408
+ */
409
+ createAssetAttachmentUploadSession(
410
+ twinId: string,
411
+ assetId: string,
412
+ attachmentId: string,
413
+ body: AttachmentPost,
414
+ signal?: AbortSignal
415
+ ): Promise<ApiCreateUploadSessionResponse>;
416
+
417
+ /**
418
+ * Uploads a `File` / `Blob` to an existing upload session in one or more
419
+ * chunks, then resolves the upload token so the caller can poll
420
+ * {@link getAssetAttachmentUploadStatus}.
421
+ */
422
+ uploadAssetAttachmentFile(
423
+ session: ApiCreateUploadSessionResponse,
424
+ file: Blob,
425
+ signal?: AbortSignal
426
+ ): Promise<{ token: string }>;
427
+
428
+ /** Polls the in-memory upload-session cache for an attachment that's being uploaded. */
429
+ getAssetAttachmentUploadStatus(
430
+ twinId: string,
431
+ assetId: string,
432
+ token: string,
433
+ signal?: AbortSignal
434
+ ): Promise<UploadStatusGet>;
435
+
436
+ /**
437
+ * Updates an attachment's description / soft-delete flag. `documentId`
438
+ * is immutable — rebinding is rejected.
439
+ * @throws {AssetAttachmentConflictError} On etag mismatch (409). The
440
+ * error's `currentAttachment` carries the server's current state.
441
+ * @throws {AssetAttachmentNotFoundError} When the attachment does not exist (404).
442
+ */
443
+ putAssetAttachment(
444
+ twinId: string,
445
+ assetId: string,
446
+ attachmentId: string,
447
+ body: AssetAttachmentPut,
448
+ signal?: AbortSignal
449
+ ): Promise<AttachmentGet>;
450
+
451
+ /**
452
+ * Soft-deletes an attachment.
453
+ * @throws {AssetAttachmentConflictError} On etag mismatch (409).
454
+ * @throws {AssetAttachmentNotFoundError} When the attachment does not exist (404).
455
+ */
456
+ deleteAssetAttachment(
457
+ twinId: string,
458
+ assetId: string,
459
+ attachmentId: string,
460
+ etag: string,
461
+ signal?: AbortSignal
462
+ ): Promise<AttachmentGet>;
463
+
464
+ /**
465
+ * Restores a soft-deleted attachment.
466
+ * @throws {AssetAttachmentConflictError} On etag mismatch (409).
467
+ * @throws {AssetAttachmentNotFoundError} When the attachment does not exist (404).
468
+ */
469
+ restoreAssetAttachment(
470
+ twinId: string,
471
+ assetId: string,
472
+ attachmentId: string,
473
+ etag: string,
474
+ signal?: AbortSignal
475
+ ): Promise<AttachmentGet>;
476
+
477
+ /**
478
+ * @throws {TwinNotFoundError} When the twin does not exist (404).
479
+ * @throws {TwinConflictError} On etag mismatch (409). The error's `currentTwin` contains the server's current state.
480
+ */
481
+ deleteTwin(twinId: string, etag: string, signal?: AbortSignal): Promise<TwinGet>;
482
+
483
+ /** @throws {TwinFileValidationError} On invalid query parameters (400). */
484
+ listTwinFiles(
485
+ twinId: string,
486
+ options?: TwinFileListOptions,
487
+ signal?: AbortSignal
488
+ ): Promise<PaginatedResponse<TwinFileGet>>;
489
+
490
+ /**
491
+ * @throws {TwinFileNotFoundError} When the file does not exist (404).
492
+ * @throws {TwinFileValidationError} On invalid request (400).
493
+ */
494
+ getTwinFile(twinId: string, fileId: string, signal?: AbortSignal): Promise<TwinFileGet>;
495
+
496
+ /**
497
+ * @throws {TwinFileValidationError} On invalid request body (400).
498
+ * @throws {TwinFileConflictError} On etag mismatch (409). The error's `currentTwinFile` contains the server's current state.
499
+ */
500
+ createOrUpdateTwinFile(
501
+ twinId: string,
502
+ fileId: string,
503
+ twinFile: TwinFilePut,
504
+ signal?: AbortSignal
505
+ ): Promise<TwinFileGet>;
506
+
507
+ /**
508
+ * @throws {TwinFileNotFoundError} When the file does not exist (404).
509
+ * @throws {TwinFileConflictError} On etag mismatch (409). The error's `currentTwinFile` contains the server's current state.
510
+ * @throws {TwinFileValidationError} On invalid request (400).
511
+ */
512
+ deleteTwinFile(
513
+ twinId: string,
514
+ fileId: string,
515
+ deleteRequest: TwinFileDelete,
516
+ signal?: AbortSignal
517
+ ): Promise<TwinFileGet>;
518
+
519
+ /**
520
+ * @throws {TwinFileNotFoundError} When the file does not exist (404).
521
+ * @throws {TwinFileConflictError} On etag mismatch (409). The error's `currentTwinFile` contains the server's current state.
522
+ * @throws {TwinFileValidationError} On invalid request (400).
523
+ */
524
+ restoreTwinFile(twinId: string, fileId: string, etag: string, signal?: AbortSignal): Promise<TwinFileGet>;
525
+
526
+ /** @throws {TwinFileNotFoundError} When the file does not exist (404). */
527
+ getTwinFileContent(twinId: string, fileId: string, signal?: AbortSignal): Promise<Blob>;
528
+
529
+ /** @throws {TwinFileNotFoundError} When the file or version does not exist (404). */
530
+ getTwinFileVersion(twinId: string, fileId: string, version: number, signal?: AbortSignal): Promise<TwinFileGet>;
531
+
532
+ /** @throws {TwinFileValidationError} On invalid query parameters (400). */
533
+ listTwinFileVersions(
534
+ twinId: string,
535
+ fileId: string,
536
+ options?: TwinFileVersionListOptions,
537
+ signal?: AbortSignal
538
+ ): Promise<PaginatedResponse<TwinFileGet>>;
539
+
540
+ /** @throws {TwinFileNotFoundError} When the file or version does not exist (404). */
541
+ getTwinFileVersionContent(twinId: string, fileId: string, version: number, signal?: AbortSignal): Promise<Blob>;
542
+
543
+ // ---------- Asset categories (TWINS-3643) ----------
544
+
545
+ /**
546
+ * Returns the caller's named, customer-scoped capabilities against the
547
+ * Twin service — the asset-domain vocabulary `ViewAssets`, `EditAssets`,
548
+ * `DeleteAssets`, `EditAssetTypes` and `ManageAssetPermissions`.
549
+ * The admin app uses this to enable or disable
550
+ * buttons instead of letting the user click through to a 403.
551
+ *
552
+ * No client-side cache — every call hits the wire. Underlying scope
553
+ * grants don't shift within a session, so callers are expected to
554
+ * memoise the result themselves (e.g. via React Query / a once-on-mount
555
+ * fetch) rather than calling this on every render.
556
+ *
557
+ * Throws {@link TwinUnauthenticatedError} on 401 (no valid auth context;
558
+ * caller should redirect to login). Returns an empty `permissions` array
559
+ * on 403 (authenticated but no Twin-service capabilities). Unknown
560
+ * capability strings should be ignored — adding a new capability is a
561
+ * non-breaking change.
562
+ *
563
+ * A 503 throws rather than answering with an empty array: the Permission
564
+ * service gave no answer, so the capabilities are unknown rather than
565
+ * absent, and the call is safe to retry unchanged.
566
+ */
567
+ getMyPermissions(signal?: AbortSignal): Promise<MyPermissions>;
568
+
569
+ // ---------- Twin-scoped permissions (TWINS-2982) ----------
570
+
571
+ /**
572
+ * Returns the capabilities the caller holds on one specific twin. Open to every
573
+ * authenticated caller — an empty list is an honest answer rather than a refusal —
574
+ * so a UI can gate its controls on this before it knows anything about the caller.
575
+ *
576
+ * The answer mixes vocabularies: the twin's own `TwinScopedCapability` values, plus the
577
+ * asset-instance capabilities `ViewAssets`, `EditAssets` and `DeleteAssets`, which gate the
578
+ * assets hanging off this twin. The catalog capabilities and `ManageAssetPermissions` are not
579
+ * twin-bound and are absent here.
580
+ *
581
+ * Distinct from {@link getMyPermissions}, which answers the customer-scoped question over the
582
+ * whole asset domain. That one returns an empty list on 403; this route has no capability gate
583
+ * to refuse at, so a 403 here means something outside the Twin service refused, and is thrown.
584
+ *
585
+ * @throws {TwinNotFoundError} The twin does not exist, or the caller cannot see it — the
586
+ * surface does not distinguish the two.
587
+ * @throws {TwinUnauthenticatedError} No valid auth context (401).
588
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
589
+ * safe to retry unchanged.
590
+ */
591
+ getMyTwinPermissions(twinId: string, signal?: AbortSignal): Promise<MyTwinPermissions>;
592
+
593
+ /**
594
+ * Lists one page of the permission entries on every scope the twin owns. Each entry carries
595
+ * the etag of the scope it lives on, which is the value to send back when mutating that
596
+ * principal.
597
+ *
598
+ * `pagination.nextPage` is emitted unconditionally — it is non-null on the last page too — so
599
+ * it is not a termination signal. Page until a response comes back with fewer entries than the
600
+ * limit asked for, or use {@link listAllTwinPermissions}, which does that. On an access-review
601
+ * surface, stopping early under-reports who holds capabilities on the twin.
602
+ *
603
+ * @throws {TwinPermissionsForbiddenError} The caller may see the twin but does not hold
604
+ * `ManageTwinPermissions` on it (403).
605
+ * @throws {TwinNotFoundError} The twin does not exist or the caller cannot see it (404).
606
+ * @throws {TwinValidationError} The limit is out of range, or the pagination token is invalid
607
+ * or does not match the query it was issued for (400).
608
+ * @throws {TwinPermissionsConflictError} `ScopesNotProvisioned` — the twin owns no Twinfinity
609
+ * scope, so there is no permission state to read (409). Retrying will not help.
610
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
611
+ * safe to retry unchanged.
612
+ */
613
+ listTwinPermissions(
614
+ twinId: string,
615
+ options?: { page?: string; limit?: number },
616
+ signal?: AbortSignal
617
+ ): Promise<PaginatedResponse<TwinPrincipalPermissionsGet>>;
618
+
619
+ /**
620
+ * Every permission entry on the twin, following the pagination to the end. The whole
621
+ * access-control list of one twin is a handful of entries, and reviewing it is the point of
622
+ * the surface, so this — rather than {@link listTwinPermissions} — is what most callers want.
623
+ *
624
+ * Throws whatever {@link listTwinPermissions} throws, from whichever page hit it.
625
+ */
626
+ listAllTwinPermissions(twinId: string, signal?: AbortSignal): Promise<TwinPrincipalPermissionsGet[]>;
627
+
628
+ /**
629
+ * Replaces the principal's capabilities on the twin, and returns the entry as stored
630
+ * along with the etag to chain the next write on. `permissions: []` revokes all four
631
+ * capabilities without removing an entry that carries bits outside this vocabulary.
632
+ *
633
+ * `body.etag` may be omitted only for a principal that holds nothing on the twin yet;
634
+ * see {@link TwinPermissionsPut}. Both etag refusals arrive as
635
+ * {@link TwinPermissionsConflictError}, carrying the current state to retry from.
636
+ *
637
+ * @throws {TwinValidationError} The target is immutable through this surface — the twin's own
638
+ * default groups and the seeded system principals, marked by
639
+ * {@link TwinPrincipalPermissionsGet.readOnly} (cause `SystemPrincipalImmutable`) — or the
640
+ * write asked for `ManageTwinPermissions` on the all-authenticated principal (cause
641
+ * `ManagePermissionsNotGrantableToAllAuthenticated`), which would make the whole tenant an
642
+ * administrator of the twin (400).
643
+ * @throws {TwinPrincipalNotFoundError} No principal carries this id (404).
644
+ * @throws {TwinNotFoundError} The twin does not exist or the caller cannot see it (404).
645
+ * @throws {TwinPermissionsForbiddenError} `ManageTwinPermissions` is required (403).
646
+ * @throws {TwinPermissionsConflictError} `EtagRequired`, `EtagMismatch` or
647
+ * `ScopesNotProvisioned` (409); the first two carry `currentState`.
648
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
649
+ * safe to retry unchanged.
650
+ */
651
+ setTwinPermissions(
652
+ twinId: string,
653
+ principalId: string,
654
+ body: TwinPermissionsPut,
655
+ signal?: AbortSignal
656
+ ): Promise<TwinPrincipalPermissionsGet>;
657
+
658
+ /**
659
+ * Removes the principal from the twin's access control list entirely — including any
660
+ * bits outside this capability vocabulary, which is what separates it from a
661
+ * {@link setTwinPermissions} call with an empty list.
662
+ *
663
+ * The etag is always required: a delete always replaces existing grants.
664
+ *
665
+ * @throws {TwinPrincipalNotFoundError} The principal holds nothing on the twin, so there is
666
+ * nothing to revoke and no entry to have read an etag from — whatever value was sent (404).
667
+ * @throws {TwinValidationError} The principal is immutable through this surface (400), decided
668
+ * before the etag is looked at.
669
+ * @throws {TwinNotFoundError} The twin does not exist or the caller cannot see it (404).
670
+ * @throws {TwinPermissionsForbiddenError} `ManageTwinPermissions` is required (403).
671
+ * @throws {TwinPermissionsConflictError} `EtagMismatch` or `ScopesNotProvisioned` (409).
672
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
673
+ * safe to retry unchanged.
674
+ */
675
+ deleteTwinPermissions(
676
+ twinId: string,
677
+ principalId: string,
678
+ etag: string,
679
+ signal?: AbortSignal
680
+ ): Promise<TwinPrincipalPermissionsGet>;
681
+
682
+ /**
683
+ * Returns the three default groups the twin owns — `TwinReaders`, `TwinContributors`,
684
+ * `TwinAdministrators` — under the ids to name in {@link setTwinPermissions}.
685
+ *
686
+ * Not a picker for the principals a twin can be granted to: that is every group in the
687
+ * tenant, which the Permission service lists. These are the groups the twin itself
688
+ * owns, whose membership confers its preset capability levels.
689
+ *
690
+ * @throws {TwinPermissionsForbiddenError} `ManageTwinPermissions` is required (403).
691
+ * @throws {TwinNotFoundError} The twin does not exist or the caller cannot see it (404).
692
+ * @throws {TwinPermissionsConflictError} `ScopesNotProvisioned` (409).
693
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
694
+ * safe to retry unchanged.
695
+ */
696
+ listTwinGroups(twinId: string, signal?: AbortSignal): Promise<TwinGroupsGet>;
697
+
698
+ /**
699
+ * Returns one of the twin's own default groups.
700
+ *
701
+ * @throws {TwinPrincipalNotFoundError} The twin does not own this group — thrown even when the
702
+ * group exists in the tenant and holds capabilities on this twin, since ownership, not
703
+ * existence, is what this route answers (404).
704
+ * @throws {TwinNotFoundError} The twin does not exist or the caller cannot see it (404).
705
+ * @throws {TwinPermissionsForbiddenError} `ManageTwinPermissions` is required (403).
706
+ * @throws {TwinPermissionsConflictError} `ScopesNotProvisioned` (409).
707
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
708
+ * safe to retry unchanged.
709
+ */
710
+ getTwinGroup(twinId: string, groupId: string, signal?: AbortSignal): Promise<TwinGroupGet>;
711
+
712
+ // ---------- Asset-domain permissions ----------
713
+
714
+ /**
715
+ * Lists one page of the principals holding capabilities on the customer-wide asset domain, with
716
+ * the capabilities each of them holds. The companion administration surface to
717
+ * {@link getMyPermissions}, which answers only for the caller.
718
+ *
719
+ * `pagination.nextPage` is emitted unconditionally — it is non-null on the last page too — so it
720
+ * is not a termination signal. Page until a response comes back with fewer entries than the
721
+ * limit asked for, or use {@link listAllAssetPermissions}, which does that. On an access-review
722
+ * surface, stopping early under-reports who holds capabilities.
723
+ *
724
+ * @throws {TwinPermissionsForbiddenError} The caller does not hold `ManageAssetPermissions`
725
+ * (403).
726
+ * @throws {TwinValidationError} The limit is out of range, or the pagination token is invalid
727
+ * (400).
728
+ * @throws {TwinUnauthenticatedError} No valid auth context (401).
729
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
730
+ * safe to retry unchanged.
731
+ */
732
+ listAssetPermissions(
733
+ options?: { page?: string; limit?: number },
734
+ signal?: AbortSignal
735
+ ): Promise<PaginatedResponse<AssetPrincipalPermissionsGet>>;
736
+
737
+ /** Every entry of {@link listAssetPermissions}, paged to exhaustion. */
738
+ listAllAssetPermissions(signal?: AbortSignal): Promise<AssetPrincipalPermissionsGet[]>;
739
+
740
+ /**
741
+ * Replaces what one principal holds on the asset domain. Capabilities absent from
742
+ * `body.permissions` are revoked, and an empty list revokes everything — leaving the principal
743
+ * with no listing entry at all, exactly as {@link deleteAssetPermissions} does.
744
+ *
745
+ * `body.etag` is required whenever the principal already holds capabilities, and may be omitted
746
+ * only to create the grants of one that holds none.
747
+ *
748
+ * @throws {TwinPrincipalNotFoundError} No group in the tenant carries this id (404).
749
+ * @throws {TwinValidationError} `SystemPrincipalImmutable` — a seeded default group, whose
750
+ * preset is conferred by membership rather than by writing here — or
751
+ * `ManagePermissionsNotGrantableToAllAuthenticated` (400).
752
+ * @throws {AssetPermissionsConflictError} `EtagRequired` or `EtagMismatch` (409), carrying the
753
+ * current state to retry from.
754
+ * @throws {TwinPermissionsForbiddenError} The caller does not hold `ManageAssetPermissions`
755
+ * (403).
756
+ * @throws {TwinUnauthenticatedError} No valid auth context (401).
757
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
758
+ * safe to retry unchanged.
759
+ */
760
+ setAssetPermissions(
761
+ principalId: string,
762
+ body: AssetPermissionsPut,
763
+ signal?: AbortSignal
764
+ ): Promise<AssetPrincipalPermissionsGet>;
765
+
766
+ /**
767
+ * Removes the principal from the domain's access control list entirely, answering with it and
768
+ * an empty capability list.
769
+ *
770
+ * The answer carries the `etag` the revocation wrote: it belongs to the domain, not to the
771
+ * principal, so a revoked principal having no listing entry does not cost the caller one. Chain
772
+ * the next write on it exactly as with {@link setAssetPermissions}, without re-reading
773
+ * {@link listAssetPermissions}.
774
+ *
775
+ * The etag is always required — a revocation always replaces existing grants — and a request
776
+ * naming none is refused with a `400` rather than a conflict, before anything else is asked.
777
+ *
778
+ * @throws {TwinPrincipalNotFoundError} No group in the tenant carries this id, or it holds
779
+ * nothing here for a revocation to remove (404).
780
+ * @throws {TwinValidationError} `SystemPrincipalImmutable` or `EtagRequired` (400).
781
+ * @throws {AssetPermissionsConflictError} `EtagMismatch` (409).
782
+ * @throws {TwinPermissionsForbiddenError} The caller does not hold `ManageAssetPermissions`
783
+ * (403).
784
+ * @throws {TwinUnauthenticatedError} No valid auth context (401).
785
+ * @throws {TwinTemporarilyUnavailableError} The request could not be completed (503);
786
+ * safe to retry unchanged.
787
+ */
788
+ deleteAssetPermissions(
789
+ principalId: string,
790
+ etag: string,
791
+ signal?: AbortSignal
792
+ ): Promise<AssetPrincipalPermissionsGet>;
793
+
794
+ /**
795
+ * Returns the asset domain's default groups — `AssetsReaders`, `AssetsContributors`,
796
+ * `AssetsAdministrators` — under the ids whose membership confers their preset capabilities.
797
+ *
798
+ * A group whose seeding has not completed for this customer is omitted rather than listed under
799
+ * a placeholder title, so a short list is a state to poll rather than an error.
800
+ *
801
+ * @throws {TwinPermissionsForbiddenError} The caller does not hold `ManageAssetPermissions`
802
+ * (403).
803
+ * @throws {TwinUnauthenticatedError} No valid auth context (401).
804
+ * @throws {TwinTemporarilyUnavailableError} The titles could not be resolved (503); safe to
805
+ * retry unchanged.
806
+ */
807
+ listAssetGroups(signal?: AbortSignal): Promise<AssetGroupsGet>;
808
+
809
+ /**
810
+ * Returns one default group by its stable name, case-insensitively.
811
+ *
812
+ * @throws {TwinPrincipalNotFoundError} The name is one of the three but its group has not been
813
+ * seeded for this customer yet — safe to retry (404).
814
+ * @throws {TwinNotFoundError} The name is not one of the three; retrying will not help (404).
815
+ * @throws {TwinPermissionsForbiddenError} The caller does not hold `ManageAssetPermissions`
816
+ * (403).
817
+ * @throws {TwinUnauthenticatedError} No valid auth context (401).
818
+ * @throws {TwinTemporarilyUnavailableError} The title could not be resolved (503); safe to
819
+ * retry unchanged.
820
+ */
821
+ getAssetGroup(name: string, signal?: AbortSignal): Promise<AssetGroupGet>;
822
+
823
+ /**
824
+ * Returns the Twinfinity meta-schema document — the JSON Schema describing
825
+ * valid asset-type metadata schemas. The bytes the backend itself
826
+ * validates against, served verbatim. Power-user tools (Monaco-backed
827
+ * JSON editors, schema-aware tooling) fetch this at runtime so their
828
+ * in-editor diagnostics line up with what the server will accept,
829
+ * decoupled from the FE bundle's build time.
830
+ */
831
+ getAssetTypeMetaSchema(signal?: AbortSignal): Promise<Record<string, unknown>>;
832
+
833
+ /**
834
+ * @throws {AssetCategoryValidationError} On invalid query parameters (400).
835
+ */
836
+ listAssetCategories(
837
+ options?: { page?: string; limit?: number; includeDeleted?: boolean },
838
+ signal?: AbortSignal
839
+ ): Promise<PaginatedResponse<AssetCategoryGet>>;
840
+
841
+ /** @throws {AssetCategoryNotFoundError} When the category does not exist (404). */
842
+ getAssetCategory(categoryId: string, signal?: AbortSignal): Promise<AssetCategoryGet>;
843
+
844
+ /**
845
+ * Upserts an asset category. Provide an empty/missing etag for first writes
846
+ * and the existing etag to update.
847
+ *
848
+ * @throws {AssetCategoryValidationError} On invalid request body (400).
849
+ * @throws {AssetCategoryConflictError} On etag mismatch (409). The error's `currentCategory` contains the server's current state.
850
+ */
851
+ putAssetCategory(categoryId: string, body: AssetCategoryPut, signal?: AbortSignal): Promise<AssetCategoryGet>;
852
+
853
+ /**
854
+ * Returns every version of a single asset category, newest first. Mirrors
855
+ * `listAssetTypeVersions` and `twin.get_twin_versions`.
856
+ *
857
+ * @throws {AssetCategoryNotFoundError} When the category's header row does not exist (404).
858
+ */
859
+ listAssetCategoryVersions(
860
+ categoryId: string,
861
+ options?: { page?: string; limit?: number; includeDeleted?: boolean },
862
+ signal?: AbortSignal
863
+ ): Promise<PaginatedResponse<AssetCategoryGet>>;
864
+
865
+ /**
866
+ * Soft-deletes a category. Inserts a new asset_category_versions row with
867
+ * `is_deleted=true`; the previous version is retained as an audit trail.
868
+ *
869
+ * @throws {AssetCategoryNotFoundError} When the category does not exist (404).
870
+ * @throws {AssetCategoryConflictError} On etag mismatch (409).
871
+ */
872
+ deleteAssetCategory(categoryId: string, etag: string, signal?: AbortSignal): Promise<AssetCategoryGet>;
873
+
874
+ /**
875
+ * Restores a soft-deleted category. Inserts a new asset_category_versions
876
+ * row with `is_deleted=false`; mirrors twin restore.
877
+ *
878
+ * @throws {AssetCategoryNotFoundError} When the category does not exist (404).
879
+ * @throws {AssetCategoryConflictError} On etag mismatch (409).
880
+ */
881
+ restoreAssetCategory(categoryId: string, etag: string, signal?: AbortSignal): Promise<AssetCategoryGet>;
882
+
883
+ // ---------- Asset types (TWINS-3643) ----------
884
+
885
+ /** @throws {AssetTypeValidationError} On invalid query parameters (400). */
886
+ listAssetTypes(
887
+ categoryId: string,
888
+ options?: { page?: string; limit?: number; includeDeleted?: boolean },
889
+ signal?: AbortSignal
890
+ ): Promise<PaginatedResponse<AssetTypeGet>>;
891
+
892
+ /** @throws {AssetTypeNotFoundError} When the category or type does not exist (404). */
893
+ getAssetType(categoryId: string, typeId: string, signal?: AbortSignal): Promise<AssetTypeGet>;
894
+
895
+ /**
896
+ * Returns every version of a single asset type, newest first. Mirrors
897
+ * `twin.get_twin_versions`.
898
+ *
899
+ * @throws {AssetTypeNotFoundError} When the type's header row does not exist (404).
900
+ */
901
+ listAssetTypeVersions(
902
+ categoryId: string,
903
+ typeId: string,
904
+ options?: { page?: string; limit?: number; includeDeleted?: boolean },
905
+ signal?: AbortSignal
906
+ ): Promise<PaginatedResponse<AssetTypeGet>>;
907
+
908
+ /**
909
+ * Upserts an asset type. The metadata schema is validated against the
910
+ * Twinfinity meta-schema at the API boundary; invalid schemas surface as
911
+ * a 400 with `cause: MetadataSchemaInvalid`.
912
+ *
913
+ * @throws {AssetTypeValidationError} On invalid request body (400). The error's `errorBody` carries the structured cause.
914
+ * @throws {AssetTypeConflictError} On etag mismatch (409). The error's `currentType` contains the server's current state.
915
+ */
916
+ putAssetType(categoryId: string, typeId: string, body: AssetTypePut, signal?: AbortSignal): Promise<AssetTypeGet>;
917
+
918
+ /**
919
+ * Soft-deletes a type. Inserts a new asset_type_versions row with
920
+ * `is_deleted=true`; the previous version is retained as an audit trail.
921
+ *
922
+ * @throws {AssetTypeNotFoundError} When the type does not exist (404).
923
+ * @throws {AssetTypeConflictError} On etag mismatch (409).
924
+ */
925
+ deleteAssetType(categoryId: string, typeId: string, etag: string, signal?: AbortSignal): Promise<AssetTypeGet>;
926
+
927
+ /**
928
+ * Restores a soft-deleted type. Inserts a new asset_type_versions row with
929
+ * `is_deleted=false`; mirrors twin restore.
930
+ *
931
+ * @throws {AssetTypeNotFoundError} When the type does not exist (404).
932
+ * @throws {AssetTypeConflictError} On etag mismatch (409).
933
+ */
934
+ restoreAssetType(categoryId: string, typeId: string, etag: string, signal?: AbortSignal): Promise<AssetTypeGet>;
935
+
936
+ /**
937
+ * Returns the customer's asset-type catalog: categories nested with
938
+ * their latest non-deleted types and full metadata schemas inline. One
939
+ * round-trip is enough to populate a picker dropdown and render any
940
+ * per-type metadata form.
941
+ */
942
+ getAssetTypeCatalog(signal?: AbortSignal): Promise<AssetCatalogResponse>;
943
+ }
944
+
945
+ /**
946
+ * The `Retry-After` of a 503, in seconds, when the response carried a usable one. The Twin service
947
+ * sends a delta-seconds value; an HTTP-date is permitted by RFC 7231 and simply yields nothing here
948
+ * rather than a nonsense delay.
949
+ */
950
+ function retryAfterSeconds(response: Response): number | undefined {
951
+ const header = response.headers?.get('Retry-After');
952
+ if (header === null || header === undefined) return undefined;
953
+
954
+ const seconds = Number(header);
955
+ return Number.isInteger(seconds) && seconds >= 0 ? seconds : undefined;
956
+ }
957
+
958
+ /**
959
+ * The batch response body carried by a failed batch, or `undefined` when the body is not one.
960
+ *
961
+ * A rolled-back batch answers with per-item results under the status of its *last failing item* --
962
+ * so the same endpoint returns a batch body under 400, 404 or 409, while a whole-request rejection
963
+ * (an empty batch, duplicate ids, a body/URL twin mismatch, oversized metadata) returns problem
964
+ * details under 400 as well. Status alone cannot tell those apart, and branching on it reported
965
+ * every per-item 400 -- a cross-twin id, a deleted asset, metadata failing its schema -- as a
966
+ * validation failure with no results for the caller to inspect.
967
+ */
968
+ async function readBatchResponse(response: Response): Promise<AssetBatchResponse | undefined> {
969
+ // Parse a clone: json() consumes the stream, and a body that turns out not to be a batch
970
+ // response still has to be readable as problem details or as text by the caller. Without
971
+ // clone -- some test doubles -- the probe is skipped rather than risk consuming the body.
972
+ if (typeof response.clone !== 'function') return undefined;
973
+ try {
974
+ const parsed = (await response.clone().json()) as AssetBatchResponse;
975
+ return Array.isArray(parsed?.results) ? parsed : undefined;
976
+ } catch {
977
+ return undefined;
978
+ }
979
+ }
980
+
981
+ export class TwinClient implements ITwinClient {
982
+ private readonly _baseUrl: string;
983
+ private readonly _assetTypeCatalogUrl: string;
984
+
985
+ constructor(
986
+ baseUrl: string | URL,
987
+ private readonly _httpClient: TwinfinityHttpClient
988
+ ) {
989
+ const base = String(baseUrl).replace(/\/+$/, '');
990
+ this._baseUrl = `${base}/_ps/twins`;
991
+ this._assetTypeCatalogUrl = `${base}/_ps/asset-type-catalog`;
992
+ }
993
+
994
+ /**
995
+ * Rooted twins base URL — typically `<host>/sites/<container>/_ps/twins`.
996
+ * Exposed so callers that want to build content URLs in the same auth
997
+ * scope as the metadata calls can derive them from here. The bare
998
+ * `<host>/_ps/twins/...` variant is not necessarily an authorized route
999
+ * on all deployments.
1000
+ */
1001
+ get baseUrl(): string {
1002
+ return this._baseUrl;
1003
+ }
1004
+
1005
+ listTwins(
1006
+ options?: {
1007
+ relatedTo?: string;
1008
+ includeDeleted?: boolean;
1009
+ page?: string;
1010
+ limit?: number;
1011
+ },
1012
+ signal?: AbortSignal
1013
+ ): Promise<PaginatedResponse<ListTwinItem>>;
1014
+ /**
1015
+ * @deprecated Use the options-object form: `listTwins({ relatedTo, page, limit }, signal)`.
1016
+ * The positional form has no `includeDeleted` parameter; that is only reachable via the options object.
1017
+ */
1018
+ listTwins(
1019
+ relatedTo?: string,
1020
+ page?: string,
1021
+ limit?: number,
1022
+ signal?: AbortSignal
1023
+ ): Promise<PaginatedResponse<ListTwinItem>>;
1024
+ async listTwins(
1025
+ optionsOrRelatedTo?:
1026
+ | {
1027
+ relatedTo?: string;
1028
+ includeDeleted?: boolean;
1029
+ page?: string;
1030
+ limit?: number;
1031
+ }
1032
+ | string,
1033
+ pageOrSignal?: string | AbortSignal,
1034
+ positionalLimit?: number,
1035
+ positionalSignal?: AbortSignal
1036
+ ): Promise<PaginatedResponse<ListTwinItem>> {
1037
+ let relatedTo: string | undefined;
1038
+ let includeDeleted = false;
1039
+ let page: string | undefined;
1040
+ let limit = 100;
1041
+ let signal: AbortSignal | undefined;
1042
+
1043
+ if (typeof optionsOrRelatedTo === 'string') {
1044
+ relatedTo = optionsOrRelatedTo;
1045
+ page = pageOrSignal as string | undefined;
1046
+ limit = positionalLimit ?? 100;
1047
+ signal = positionalSignal;
1048
+ } else {
1049
+ ({ relatedTo, includeDeleted = false, page = undefined, limit = 100 } = optionsOrRelatedTo ?? {});
1050
+ signal = pageOrSignal as AbortSignal | undefined;
1051
+ }
1052
+
1053
+ const params = new URLSearchParams();
1054
+ if (relatedTo) params.set('relatedTo', relatedTo);
1055
+ if (page !== undefined) params.set('page', page);
1056
+ if (limit !== 100) params.set('limit', limit.toString());
1057
+ if (includeDeleted) params.set('includeDeleted', 'true');
1058
+
1059
+ const url = params.toString() ? `${this._baseUrl}?${params}` : this._baseUrl;
1060
+
1061
+ const response = await this._httpClient.fetch(HttpMethod.Get, url, {
1062
+ headers: { Accept: 'application/json' },
1063
+ signal
1064
+ });
1065
+
1066
+ if (response.ok) {
1067
+ return (await response.json()) as PaginatedResponse<ListTwinItem>;
1068
+ }
1069
+
1070
+ if (response.status === 400) {
1071
+ const errorBody = await readErrorBody(response);
1072
+ throw new TwinValidationError(`Twin listing validation failed: ${errorBody.detail}`, errorBody);
1073
+ }
1074
+
1075
+ const errorText = await response.text();
1076
+ throw new Error(`Failed to list twins: ${response.status} ${response.statusText} - ${errorText}`);
1077
+ }
1078
+
1079
+ listAllTwins(
1080
+ options?: { relatedTo?: string; includeDeleted?: boolean },
1081
+ signal?: AbortSignal
1082
+ ): Promise<ListTwinItem[]>;
1083
+ /**
1084
+ * @deprecated Use the options-object form: `listAllTwins({ relatedTo }, signal)`.
1085
+ * The positional form has no `includeDeleted` parameter; that is only reachable via the options object.
1086
+ */
1087
+ listAllTwins(relatedTo?: string, signal?: AbortSignal): Promise<ListTwinItem[]>;
1088
+ async listAllTwins(
1089
+ optionsOrRelatedTo?: { relatedTo?: string; includeDeleted?: boolean } | string,
1090
+ signalArg?: AbortSignal
1091
+ ): Promise<ListTwinItem[]> {
1092
+ let relatedTo: string | undefined;
1093
+ let includeDeleted = false;
1094
+ let signal: AbortSignal | undefined;
1095
+
1096
+ if (typeof optionsOrRelatedTo === 'string') {
1097
+ relatedTo = optionsOrRelatedTo;
1098
+ signal = signalArg;
1099
+ } else {
1100
+ ({ relatedTo, includeDeleted = false } = optionsOrRelatedTo ?? {});
1101
+ signal = signalArg;
1102
+ }
1103
+
1104
+ const allTwins: ListTwinItem[] = [];
1105
+ let page: string | undefined = undefined;
1106
+ const limit = 100;
1107
+ const maxPages = 1000;
1108
+
1109
+ for (let pageCount = 0; pageCount < maxPages; pageCount++) {
1110
+ const response: PaginatedResponse<ListTwinItem> = await this.listTwins(
1111
+ { relatedTo, page, limit, includeDeleted },
1112
+ signal
1113
+ );
1114
+ allTwins.push(...response.data);
1115
+
1116
+ if (response.data.length < limit) {
1117
+ break;
1118
+ }
1119
+
1120
+ page = response.pagination.nextPage;
1121
+ }
1122
+
1123
+ return allTwins;
1124
+ }
1125
+
1126
+ async getTwin(twin: string | { id: string; version: number }, signal?: AbortSignal): Promise<TwinGet> {
1127
+ const twinId = typeof twin === 'string' ? twin : twin.id;
1128
+ const specifiedVersion = typeof twin === 'string' ? undefined : twin.version;
1129
+ const response = await this._httpClient.fetch(
1130
+ HttpMethod.Get,
1131
+ `${this._baseUrl}/${twinId}${specifiedVersion !== undefined ? `/versions/${specifiedVersion}` : ''}`,
1132
+ {
1133
+ headers: { Accept: 'application/json' },
1134
+ signal
1135
+ }
1136
+ );
1137
+
1138
+ if (response.ok) {
1139
+ return (await response.json()) as TwinGet;
1140
+ }
1141
+
1142
+ if (response.status === 404) {
1143
+ throw new TwinNotFoundError(`Twin ${twinId} not found`);
1144
+ }
1145
+
1146
+ const errorText = await response.text();
1147
+ throw new Error(`Failed to get twin: ${response.status} ${response.statusText} - ${errorText}`);
1148
+ }
1149
+
1150
+ async listTwinVersions(
1151
+ id: string,
1152
+ options?: {
1153
+ includeDeleted?: boolean;
1154
+ page: string | undefined;
1155
+ limit?: number;
1156
+ },
1157
+ signal?: AbortSignal
1158
+ ): Promise<PaginatedResponse<ListTwinItem>> {
1159
+ const params = new URLSearchParams();
1160
+ if (options?.page !== undefined) params.set('page', options.page);
1161
+ if (options?.limit !== undefined && options.limit !== 100) params.set('limit', options.limit.toString());
1162
+ if (options?.includeDeleted) params.set('includeDeleted', 'true');
1163
+
1164
+ const url = `${this._baseUrl}/${id}/versions${params.toString() ? `?${params}` : ''}`;
1165
+
1166
+ const response = await this._httpClient.fetch(HttpMethod.Get, url, {
1167
+ headers: { Accept: 'application/json' },
1168
+ signal
1169
+ });
1170
+
1171
+ if (response.ok) {
1172
+ return (await response.json()) as PaginatedResponse<ListTwinItem>;
1173
+ }
1174
+
1175
+ if (response.status === 400) {
1176
+ const errorBody = await readErrorBody(response);
1177
+ throw new TwinValidationError(`Twin listing validation failed: ${errorBody.detail}`, errorBody);
1178
+ }
1179
+
1180
+ const errorText = await response.text();
1181
+ throw new Error(`Failed to list twins: ${response.status} ${response.statusText} - ${errorText}`);
1182
+ }
1183
+
1184
+ async downloadTwinContent(
1185
+ twinId: string,
1186
+ twinVersion: number,
1187
+ content: TwinContentResponse,
1188
+ signal?: AbortSignal
1189
+ ): Promise<ArrayBuffer> {
1190
+ const response = await this._httpClient.fetch(
1191
+ HttpMethod.Get,
1192
+ `${this._baseUrl}/${twinId}/versions/${twinVersion}/contents/${content.id}/content`,
1193
+ { signal }
1194
+ );
1195
+ if (response.ok) {
1196
+ const blob = await response.blob();
1197
+ return await blob.arrayBuffer();
1198
+ }
1199
+
1200
+ throw new Error(`Failed to download twin content: ${response.status} ${response.statusText}`);
1201
+ }
1202
+
1203
+ private async loadTwinVersion<TProject, TProduct, TEntityType, TUnit>(
1204
+ twinId: string,
1205
+ twinVersion: number,
1206
+ projectFactory: (dto: T8ClientProjectDTO<TUnit>) => TProject,
1207
+ productFactory: (dto: T8ClientProductDTO<TUnit>) => TProduct,
1208
+ entityTypeFactory: (dto: T8ClientEntityTypeDTO<TUnit>) => TEntityType,
1209
+ unitFactory: (dto: T8ClientUnitDTO, dataModel: T8ClientModelDTO) => TUnit,
1210
+ connectProject: (dto: T8ClientProjectConnectionsDTO<TProduct>, project: TProject) => void,
1211
+ connectProduct: (dto: T8ClientProductConnectionsDTO<TProduct, TEntityType>, product: TProduct) => void,
1212
+ signal?: AbortSignal
1213
+ ): Promise<T8ClientModel<TProject, TProduct, TEntityType, TUnit> | undefined> {
1214
+ const twinContent = await this.getTwinVersionContent(twinId, twinVersion, signal);
1215
+
1216
+ const t8cmItem = twinContent.content.find((item) => item.format === 't8cm');
1217
+
1218
+ if (t8cmItem === undefined) {
1219
+ return undefined;
1220
+ }
1221
+
1222
+ const t8cmExtensions = twinContent.content.filter((item) => item.format === 't8cm-ext');
1223
+
1224
+ const data = await Promise.all(
1225
+ [this.downloadTwinContent(twinId, twinVersion, t8cmItem, signal)].concat(
1226
+ t8cmExtensions.map((ext) => this.downloadTwinContent(twinId, twinVersion, ext, signal))
1227
+ )
1228
+ );
1229
+
1230
+ return readT8ClientModelWithFactories<TProject, TProduct, TEntityType, TUnit>(
1231
+ data,
1232
+ projectFactory,
1233
+ productFactory,
1234
+ entityTypeFactory,
1235
+ unitFactory,
1236
+ connectProject,
1237
+ connectProduct
1238
+ );
1239
+ }
1240
+
1241
+ loadTwin(
1242
+ twin: string | { id: string; version: number },
1243
+ signal?: AbortSignal
1244
+ ): Promise<Twin<T8Project, T8Product<T8EntityType>, T8EntityType, T8Unit>> {
1245
+ return this.loadTwinWithFactories(
1246
+ twin,
1247
+ T8Project.create<T8Product<T8EntityType>, T8Unit>,
1248
+ T8Product.create<T8EntityType, T8Unit>,
1249
+ T8EntityType.create,
1250
+ T8Unit.create,
1251
+ T8Project.connect,
1252
+ T8Product.connect,
1253
+ signal
1254
+ );
1255
+ }
1256
+
1257
+ async loadTwinWithFactories<TProject, TProduct, TEntityType, TUnit>(
1258
+ twin: string | { id: string; version: number },
1259
+ projectFactory: (dto: T8ClientProjectDTO<TUnit>) => TProject,
1260
+ productFactory: (dto: T8ClientProductDTO<TUnit>) => TProduct,
1261
+ entityTypeFactory: (dto: T8ClientEntityTypeDTO<TUnit>) => TEntityType,
1262
+ unitFactory: (dto: T8ClientUnitDTO, dataModel: T8ClientModelDTO) => TUnit,
1263
+ connectProject: (dto: T8ClientProjectConnectionsDTO<TProduct>, project: TProject) => void,
1264
+ connectProduct: (dto: T8ClientProductConnectionsDTO<TProduct, TEntityType>, product: TProduct) => void,
1265
+ signal?: AbortSignal
1266
+ ): Promise<Twin<TProject, TProduct, TEntityType, TUnit>> {
1267
+ let page: string | undefined = undefined;
1268
+ const twinId = typeof twin === 'string' ? twin : twin.id;
1269
+ const specifiedVersion = typeof twin === 'string' ? undefined : twin.version;
1270
+ if (specifiedVersion !== undefined) {
1271
+ return {
1272
+ id: twinId,
1273
+ version: specifiedVersion,
1274
+ model: await this.loadTwinVersion(
1275
+ twinId,
1276
+ specifiedVersion,
1277
+ projectFactory,
1278
+ productFactory,
1279
+ entityTypeFactory,
1280
+ unitFactory,
1281
+ connectProject,
1282
+ connectProduct,
1283
+ signal
1284
+ )
1285
+ };
1286
+ }
1287
+
1288
+ const maxPages = 100;
1289
+ for (let pageCount = 0; pageCount < maxPages; pageCount++) {
1290
+ const versions = await this.listTwinVersions(twinId, { page }, signal);
1291
+ if (versions.data.length === 0) {
1292
+ break;
1293
+ }
1294
+ for (const versionItem of versions.data) {
1295
+ const loadedModel = await this.loadTwinVersion(
1296
+ twinId,
1297
+ versionItem.version,
1298
+ projectFactory,
1299
+ productFactory,
1300
+ entityTypeFactory,
1301
+ unitFactory,
1302
+ connectProject,
1303
+ connectProduct,
1304
+ signal
1305
+ );
1306
+ if (loadedModel !== undefined) {
1307
+ return {
1308
+ id: twinId,
1309
+ version: versionItem.version,
1310
+ model: loadedModel
1311
+ };
1312
+ }
1313
+ }
1314
+ page = versions.pagination.nextPage;
1315
+ }
1316
+
1317
+ throw new Error(`Twin ${twinId} does not have a client model`);
1318
+ }
1319
+
1320
+ private async loadGeometryInfoVersion<TMeshDescriptor, TStyle, TRegion>(
1321
+ twinId: string,
1322
+ twinVersion: number,
1323
+ meshDescriptorFactory: (dto: T8GIMeshDescriptorDTO) => TMeshDescriptor,
1324
+ styleFactory: (dto: T8GIStyleDTO) => TStyle,
1325
+ regionFactory: (dto: T8GIRegionDTO) => TRegion,
1326
+ signal?: AbortSignal
1327
+ ): Promise<T8GIModelGeneric<TMeshDescriptor, TStyle, TRegion> | undefined> {
1328
+ const twinContent = await this.getTwinVersionContent(twinId, twinVersion, signal);
1329
+
1330
+ const t8giItem = twinContent.content.find((item) => item.format === 't8gi');
1331
+
1332
+ if (t8giItem === undefined) {
1333
+ return undefined;
1334
+ }
1335
+
1336
+ const data = await this.downloadTwinContent(twinId, twinVersion, t8giItem, signal);
1337
+
1338
+ return readT8GIModelWithFactories(data, meshDescriptorFactory, styleFactory, regionFactory);
1339
+ }
1340
+
1341
+ loadGeometryInfo(
1342
+ twin: string | { id: string; version: number },
1343
+ signal?: AbortSignal
1344
+ ): Promise<TwinGeometryInfo<T8GIMeshDescriptor, T8GIStyle, T8GIRegion>> {
1345
+ return this.loadGeometryInfoWithFactories(
1346
+ twin,
1347
+ T8GIMeshDescriptor.create,
1348
+ T8GIStyle.create,
1349
+ T8GIRegion.create,
1350
+ signal
1351
+ );
1352
+ }
1353
+
1354
+ async loadGeometryInfoWithFactories<TMeshDescriptor, TStyle, TRegion>(
1355
+ twin: string | { id: string; version: number },
1356
+ meshDescriptorFactory: (dto: T8GIMeshDescriptorDTO) => TMeshDescriptor,
1357
+ styleFactory: (dto: T8GIStyleDTO) => TStyle,
1358
+ regionFactory: (dto: T8GIRegionDTO) => TRegion,
1359
+ signal?: AbortSignal
1360
+ ): Promise<TwinGeometryInfo<TMeshDescriptor, TStyle, TRegion>> {
1361
+ let page: string | undefined = undefined;
1362
+ const twinId = typeof twin === 'string' ? twin : twin.id;
1363
+ const specifiedVersion = typeof twin === 'string' ? undefined : twin.version;
1364
+
1365
+ if (specifiedVersion !== undefined) {
1366
+ return {
1367
+ id: twinId,
1368
+ version: specifiedVersion,
1369
+ model: await this.loadGeometryInfoVersion(
1370
+ twinId,
1371
+ specifiedVersion,
1372
+ meshDescriptorFactory,
1373
+ styleFactory,
1374
+ regionFactory,
1375
+ signal
1376
+ )
1377
+ };
1378
+ }
1379
+
1380
+ const maxPages = 100;
1381
+ for (let pageCount = 0; pageCount < maxPages; pageCount++) {
1382
+ const versions = await this.listTwinVersions(twinId, { page }, signal);
1383
+ if (versions.data.length === 0) {
1384
+ break;
1385
+ }
1386
+ for (const versionItem of versions.data) {
1387
+ const model = await this.loadGeometryInfoVersion(
1388
+ twinId,
1389
+ versionItem.version,
1390
+ meshDescriptorFactory,
1391
+ styleFactory,
1392
+ regionFactory,
1393
+ signal
1394
+ );
1395
+ if (model !== undefined) {
1396
+ return {
1397
+ id: twinId,
1398
+ version: versionItem.version,
1399
+ model
1400
+ };
1401
+ }
1402
+ }
1403
+ page = versions.pagination.nextPage;
1404
+ }
1405
+
1406
+ throw new Error(`Twin ${twinId} does not have geometry info`);
1407
+ }
1408
+
1409
+ private async loadTwinWithGeometryVersion(
1410
+ twinId: string,
1411
+ twinVersion: number,
1412
+ signal?: AbortSignal
1413
+ ): Promise<T8ClientModel<T8ProjectWithGeometry, T8ProductWithGeometry, T8EntityType, T8Unit> | undefined> {
1414
+ const twinContent = await this.getTwinVersionContent(twinId, twinVersion, signal);
1415
+
1416
+ const t8cmItem = twinContent.content.find((item) => item.format === 't8cm');
1417
+ const t8giItem = twinContent.content.find((item) => item.format === 't8gi');
1418
+
1419
+ if (t8cmItem === undefined || t8giItem === undefined) {
1420
+ return undefined;
1421
+ }
1422
+
1423
+ const t8cmExtensions = twinContent.content.filter((item) => item.format === 't8cm-ext');
1424
+ const t8gdItem = twinContent.content.find((item) => item.format === 't8gd');
1425
+
1426
+ const downloads = [
1427
+ this.downloadTwinContent(twinId, twinVersion, t8cmItem, signal),
1428
+ ...t8cmExtensions.map((ext) => this.downloadTwinContent(twinId, twinVersion, ext, signal)),
1429
+ this.downloadTwinContent(twinId, twinVersion, t8giItem, signal),
1430
+ ...(t8gdItem ? [this.downloadTwinContent(twinId, twinVersion, t8gdItem, signal)] : [])
1431
+ ];
1432
+
1433
+ const allData = await Promise.all(downloads);
1434
+
1435
+ const t8cmCount = 1 + t8cmExtensions.length;
1436
+ const t8cmData = allData.slice(0, t8cmCount);
1437
+ const t8giData = allData[t8cmCount];
1438
+ const t8gdData = t8gdItem ? allData[t8cmCount + 1] : undefined;
1439
+
1440
+ return readT8ClientModelWithGeometry(t8cmData, t8giData, t8gdData);
1441
+ }
1442
+
1443
+ async loadTwinWithGeometry(
1444
+ twin: string | { id: string; version: number },
1445
+ signal?: AbortSignal
1446
+ ): Promise<Twin<T8ProjectWithGeometry, T8ProductWithGeometry, T8EntityType, T8Unit>> {
1447
+ let page: string | undefined = undefined;
1448
+ const twinId = typeof twin === 'string' ? twin : twin.id;
1449
+ const specifiedVersion = typeof twin === 'string' ? undefined : twin.version;
1450
+
1451
+ if (specifiedVersion !== undefined) {
1452
+ return {
1453
+ id: twinId,
1454
+ version: specifiedVersion,
1455
+ model: await this.loadTwinWithGeometryVersion(twinId, specifiedVersion, signal)
1456
+ };
1457
+ }
1458
+
1459
+ const maxPages = 100;
1460
+ for (let pageCount = 0; pageCount < maxPages; pageCount++) {
1461
+ const versions = await this.listTwinVersions(twinId, { page }, signal);
1462
+ if (versions.data.length === 0) {
1463
+ break;
1464
+ }
1465
+ for (const versionItem of versions.data) {
1466
+ const model = await this.loadTwinWithGeometryVersion(twinId, versionItem.version, signal);
1467
+ if (model !== undefined) {
1468
+ return {
1469
+ id: twinId,
1470
+ version: versionItem.version,
1471
+ model
1472
+ };
1473
+ }
1474
+ }
1475
+ page = versions.pagination.nextPage;
1476
+ }
1477
+
1478
+ throw new Error(`Twin ${twinId} does not have a client model with geometry`);
1479
+ }
1480
+
1481
+ async createOrUpdateTwin(twinId: string, twin: TwinPut, signal?: AbortSignal): Promise<TwinGet> {
1482
+ const response = await this._httpClient.fetch(HttpMethod.Put, `${this._baseUrl}/${twinId}`, {
1483
+ headers: { 'Content-Type': 'application/json' },
1484
+ body: JSON.stringify(twin),
1485
+ signal
1486
+ });
1487
+
1488
+ if (response.ok) {
1489
+ return (await response.json()) as TwinGet;
1490
+ }
1491
+
1492
+ if (response.status === 400) {
1493
+ const errorBody = await readErrorBody(response);
1494
+ throw new TwinValidationError(`Twin validation failed: ${errorBody.detail}`, errorBody);
1495
+ }
1496
+
1497
+ if (response.status === 409) {
1498
+ const conflictedTwin = (await response.json()) as TwinGet;
1499
+ throw new TwinConflictError('Twin has been modified by another user', conflictedTwin);
1500
+ }
1501
+
1502
+ const errorText = await response.text();
1503
+ throw new Error(`Failed to create/update twin: ${response.status} ${response.statusText} - ${errorText}`);
1504
+ }
1505
+
1506
+ async updateTwin(twinId: string, twin: TwinPut, signal?: AbortSignal): Promise<TwinGet> {
1507
+ return this.createOrUpdateTwin(twinId, twin, signal);
1508
+ }
1509
+
1510
+ async deleteTwin(twinId: string, etag: string, signal?: AbortSignal): Promise<TwinGet> {
1511
+ const response = await this._httpClient.fetch(HttpMethod.Delete, `${this._baseUrl}/${twinId}`, {
1512
+ headers: { 'Content-Type': 'application/json' },
1513
+ body: JSON.stringify({ etag }),
1514
+ signal
1515
+ });
1516
+
1517
+ if (response.ok) {
1518
+ return (await response.json()) as TwinGet;
1519
+ }
1520
+
1521
+ if (response.status === 404) {
1522
+ throw new TwinNotFoundError(`Twin ${twinId} not found`);
1523
+ }
1524
+
1525
+ if (response.status === 409) {
1526
+ const conflictedTwin = (await response.json()) as TwinGet;
1527
+ throw new TwinConflictError('Twin has been modified by another user', conflictedTwin);
1528
+ }
1529
+
1530
+ const errorText = await response.text();
1531
+ throw new Error(`Failed to delete twin: ${response.status} ${response.statusText} - ${errorText}`);
1532
+ }
1533
+
1534
+ async restoreTwin(twinId: string, etag: string, signal?: AbortSignal): Promise<TwinGet> {
1535
+ const response = await this._httpClient.fetch(HttpMethod.Put, `${this._baseUrl}/${twinId}/restore`, {
1536
+ headers: { 'Content-Type': 'application/json' },
1537
+ body: JSON.stringify({ etag }),
1538
+ signal
1539
+ });
1540
+
1541
+ if (response.ok) {
1542
+ return (await response.json()) as TwinGet;
1543
+ }
1544
+
1545
+ if (response.status === 404) {
1546
+ throw new TwinNotFoundError(`Twin ${twinId} not found`);
1547
+ }
1548
+
1549
+ if (response.status === 409) {
1550
+ const conflictedTwin = (await response.json()) as TwinGet;
1551
+ throw new TwinConflictError('Twin has been modified by another user', conflictedTwin);
1552
+ }
1553
+
1554
+ const errorText = await response.text();
1555
+ throw new Error(`Failed to restore twin: ${response.status} ${response.statusText} - ${errorText}`);
1556
+ }
1557
+
1558
+ async getTwinVersionContent(
1559
+ twinId: string,
1560
+ twinVersion: number,
1561
+ signal?: AbortSignal
1562
+ ): Promise<TwinVersionContentResponse> {
1563
+ const response = await this._httpClient.fetch(
1564
+ HttpMethod.Get,
1565
+ `${this._baseUrl}/${twinId}/versions/${twinVersion}/contents`,
1566
+ { headers: { Accept: 'application/json' }, signal }
1567
+ );
1568
+
1569
+ if (response.ok) {
1570
+ return (await response.json()) as TwinVersionContentResponse;
1571
+ }
1572
+
1573
+ if (response.status === 404) {
1574
+ throw new TwinNotFoundError(`Twin ${twinId} version ${twinVersion} not found`);
1575
+ }
1576
+
1577
+ const errorText = await response.text();
1578
+ throw new Error(`Failed to get twin version content: ${response.status} ${response.statusText} - ${errorText}`);
1579
+ }
1580
+
1581
+ async getTwinContent(twinId: string, signal?: AbortSignal): Promise<TwinVersionContentResponse> {
1582
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/contents`, {
1583
+ headers: { Accept: 'application/json' },
1584
+ signal
1585
+ });
1586
+
1587
+ if (response.ok) {
1588
+ return (await response.json()) as TwinVersionContentResponse;
1589
+ }
1590
+
1591
+ if (response.status === 404) {
1592
+ throw new TwinNotFoundError(`Twin ${twinId} not found`);
1593
+ }
1594
+
1595
+ const errorText = await response.text();
1596
+ throw new Error(`Failed to get twin content: ${response.status} ${response.statusText} - ${errorText}`);
1597
+ }
1598
+
1599
+ async getTwinDiagnostics(twinId: string, signal?: AbortSignal): Promise<TwinDiagnosticsResponse> {
1600
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/diagnostics`, {
1601
+ headers: { Accept: 'application/json' },
1602
+ signal
1603
+ });
1604
+
1605
+ if (response.ok) {
1606
+ return (await response.json()) as TwinDiagnosticsResponse;
1607
+ }
1608
+
1609
+ if (response.status === 404) {
1610
+ throw new TwinNotFoundError(`Twin ${twinId} not found`);
1611
+ }
1612
+
1613
+ const errorText = await response.text();
1614
+ throw new Error(`Failed to get twin diagnostics: ${response.status} ${response.statusText} - ${errorText}`);
1615
+ }
1616
+
1617
+ async getTwinVersionDiagnostics(
1618
+ twinId: string,
1619
+ twinVersion: number,
1620
+ signal?: AbortSignal
1621
+ ): Promise<TwinDiagnosticsResponse> {
1622
+ const response = await this._httpClient.fetch(
1623
+ HttpMethod.Get,
1624
+ `${this._baseUrl}/${twinId}/versions/${twinVersion}/diagnostics`,
1625
+ { headers: { Accept: 'application/json' }, signal }
1626
+ );
1627
+
1628
+ if (response.ok) {
1629
+ return (await response.json()) as TwinDiagnosticsResponse;
1630
+ }
1631
+
1632
+ if (response.status === 404) {
1633
+ throw new TwinNotFoundError(`Twin ${twinId} version ${twinVersion} not found`);
1634
+ }
1635
+
1636
+ const errorText = await response.text();
1637
+ throw new Error(
1638
+ `Failed to get twin version diagnostics: ${response.status} ${response.statusText} - ${errorText}`
1639
+ );
1640
+ }
1641
+
1642
+ async getTwinVersionEvents(
1643
+ twinId: string,
1644
+ twinVersion: number,
1645
+ offset = 0,
1646
+ limit = 100,
1647
+ signal?: AbortSignal
1648
+ ): Promise<PaginatedResponse<TwinProcessorEvent>> {
1649
+ const url = `${this._baseUrl}/${twinId}/versions/${twinVersion}/events?offset=${offset}&limit=${limit}`;
1650
+
1651
+ const response = await this._httpClient.fetch(HttpMethod.Get, url, {
1652
+ headers: { Accept: 'application/json' },
1653
+ signal
1654
+ });
1655
+
1656
+ if (response.ok) {
1657
+ return (await response.json()) as PaginatedResponse<TwinProcessorEvent>;
1658
+ }
1659
+
1660
+ if (response.status === 400) {
1661
+ const errorBody = await readErrorBody(response);
1662
+ throw new TwinValidationError(`Twin events validation failed: ${errorBody.detail}`, errorBody);
1663
+ }
1664
+
1665
+ if (response.status === 404) {
1666
+ throw new TwinNotFoundError(`Twin ${twinId} version ${twinVersion} not found`);
1667
+ }
1668
+
1669
+ const errorText = await response.text();
1670
+ throw new Error(`Failed to get twin version events: ${response.status} ${response.statusText} - ${errorText}`);
1671
+ }
1672
+
1673
+ async getProcessorEvents(
1674
+ twinId: string,
1675
+ twinVersion: number,
1676
+ processorId: string,
1677
+ offset = 0,
1678
+ limit = 100,
1679
+ signal?: AbortSignal
1680
+ ): Promise<PaginatedResponse<TwinProcessorEvent>> {
1681
+ const url = `${this._baseUrl}/${twinId}/versions/${twinVersion}/processors/${processorId}/events?offset=${offset}&limit=${limit}`;
1682
+
1683
+ const response = await this._httpClient.fetch(HttpMethod.Get, url, {
1684
+ headers: { Accept: 'application/json' },
1685
+ signal
1686
+ });
1687
+
1688
+ if (response.ok) {
1689
+ return (await response.json()) as PaginatedResponse<TwinProcessorEvent>;
1690
+ }
1691
+
1692
+ if (response.status === 400) {
1693
+ const errorBody = await readErrorBody(response);
1694
+ throw new TwinValidationError(`Processor events validation failed: ${errorBody.detail}`, errorBody);
1695
+ }
1696
+
1697
+ if (response.status === 404) {
1698
+ throw new ProcessorNotFoundError(
1699
+ `Processor ${processorId} not found for twin ${twinId} version ${twinVersion}`
1700
+ );
1701
+ }
1702
+
1703
+ const errorText = await response.text();
1704
+ throw new Error(`Failed to get processor events: ${response.status} ${response.statusText} - ${errorText}`);
1705
+ }
1706
+
1707
+ async listAssets(
1708
+ twinId: string,
1709
+ offset = 0,
1710
+ limit = 100,
1711
+ options?: { includeDeleted?: boolean },
1712
+ signal?: AbortSignal
1713
+ ): Promise<PaginatedResponse<AssetListItem>> {
1714
+ const includeDeleted = options?.includeDeleted ?? false;
1715
+ const params = `offset=${offset}&limit=${limit}${includeDeleted ? '&includeDeleted=true' : ''}`;
1716
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/assets?${params}`, {
1717
+ headers: { Accept: 'application/json' },
1718
+ signal
1719
+ });
1720
+
1721
+ if (response.ok) {
1722
+ return (await response.json()) as PaginatedResponse<AssetListItem>;
1723
+ }
1724
+
1725
+ if (response.status === 400) {
1726
+ const errorBody = await readErrorBody(response);
1727
+ throw new AssetValidationError(`Asset listing validation failed: ${errorBody.detail}`, errorBody);
1728
+ }
1729
+
1730
+ if (response.status === 404) {
1731
+ throw new AssetNotFoundError(`Twin ${twinId} not found`);
1732
+ }
1733
+
1734
+ const errorText = await response.text();
1735
+ throw new Error(`Failed to list assets: ${response.status} ${response.statusText} - ${errorText}`);
1736
+ }
1737
+
1738
+ async getAsset(twinId: string, assetId: string, signal?: AbortSignal): Promise<AssetGet> {
1739
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/assets/${assetId}`, {
1740
+ headers: { Accept: 'application/json' },
1741
+ signal
1742
+ });
1743
+
1744
+ if (response.ok) {
1745
+ return (await response.json()) as AssetGet;
1746
+ }
1747
+
1748
+ if (response.status === 404) {
1749
+ throw new AssetNotFoundError(`Asset ${assetId} not found`);
1750
+ }
1751
+
1752
+ const errorText = await response.text();
1753
+ throw new Error(`Failed to get asset: ${response.status} ${response.statusText} - ${errorText}`);
1754
+ }
1755
+
1756
+ async createAssets(twinId: string, assets: AssetPost[], signal?: AbortSignal): Promise<AssetGet[]> {
1757
+ const response = await this._httpClient.fetch(HttpMethod.Post, `${this._baseUrl}/${twinId}/assets`, {
1758
+ headers: { 'Content-Type': 'application/json' },
1759
+ body: JSON.stringify(assets),
1760
+ signal
1761
+ });
1762
+
1763
+ if (response.status === 201) {
1764
+ return (await response.json()) as AssetGet[];
1765
+ }
1766
+
1767
+ if (response.status === 400) {
1768
+ const errorBody = await readErrorBody(response);
1769
+ throw new AssetValidationError(`Asset validation failed: ${errorBody.detail}`, errorBody);
1770
+ }
1771
+
1772
+ const errorText = await response.text();
1773
+ throw new Error(`Failed to create assets: ${response.status} ${response.statusText} - ${errorText}`);
1774
+ }
1775
+
1776
+ async updateAsset(twinId: string, assetId: string, asset: AssetPut, signal?: AbortSignal): Promise<AssetGet> {
1777
+ const response = await this._httpClient.fetch(HttpMethod.Put, `${this._baseUrl}/${twinId}/assets/${assetId}`, {
1778
+ headers: { 'Content-Type': 'application/json' },
1779
+ body: JSON.stringify(asset),
1780
+ signal
1781
+ });
1782
+
1783
+ if (response.ok) {
1784
+ return (await response.json()) as AssetGet;
1785
+ }
1786
+
1787
+ if (response.status === 409) {
1788
+ const conflictedAsset = (await response.json()) as AssetGet;
1789
+ throw new AssetConflictError('Asset has been modified by another user', conflictedAsset);
1790
+ }
1791
+
1792
+ if (response.status === 400) {
1793
+ const errorBody = await readErrorBody(response);
1794
+ throw new AssetValidationError(`Asset validation failed: ${errorBody.detail}`, errorBody);
1795
+ }
1796
+
1797
+ const errorText = await response.text();
1798
+ throw new Error(`Failed to update asset: ${response.status} ${response.statusText} - ${errorText}`);
1799
+ }
1800
+
1801
+ async deleteAsset(twinId: string, assetId: string, etag: string, signal?: AbortSignal): Promise<AssetGet> {
1802
+ const response = await this._httpClient.fetch(
1803
+ HttpMethod.Delete,
1804
+ `${this._baseUrl}/${twinId}/assets/${assetId}`,
1805
+ {
1806
+ headers: { 'Content-Type': 'application/json' },
1807
+ body: JSON.stringify({ etag }),
1808
+ signal
1809
+ }
1810
+ );
1811
+
1812
+ if (response.ok) {
1813
+ return (await response.json()) as AssetGet;
1814
+ }
1815
+
1816
+ if (response.status === 409) {
1817
+ const conflictedAsset = (await response.json()) as AssetGet;
1818
+ throw new AssetConflictError('Asset has been modified by another user', conflictedAsset);
1819
+ }
1820
+
1821
+ if (response.status === 404) {
1822
+ throw new AssetNotFoundError(`Asset ${assetId} not found`);
1823
+ }
1824
+
1825
+ const errorText = await response.text();
1826
+ throw new Error(`Failed to delete asset: ${response.status} ${response.statusText} - ${errorText}`);
1827
+ }
1828
+
1829
+ async restoreAsset(twinId: string, assetId: string, etag: string): Promise<AssetGet> {
1830
+ const response = await this._httpClient.fetch(
1831
+ HttpMethod.Put,
1832
+ `${this._baseUrl}/${twinId}/assets/${assetId}/restore`,
1833
+ {
1834
+ headers: { 'Content-Type': 'application/json' },
1835
+ body: JSON.stringify({ etag })
1836
+ }
1837
+ );
1838
+
1839
+ if (response.ok) {
1840
+ return (await response.json()) as AssetGet;
1841
+ }
1842
+
1843
+ if (response.status === 409) {
1844
+ const conflictedAsset = (await response.json()) as AssetGet;
1845
+ throw new AssetConflictError('Asset has been modified by another user', conflictedAsset);
1846
+ }
1847
+
1848
+ if (response.status === 404) {
1849
+ throw new AssetNotFoundError(`Asset ${assetId} not found`);
1850
+ }
1851
+
1852
+ const errorText = await response.text();
1853
+ throw new Error(`Failed to restore asset: ${response.status} ${response.statusText} - ${errorText}`);
1854
+ }
1855
+
1856
+ async batchAssets(
1857
+ twinId: string,
1858
+ operations: AssetBatchOperation[],
1859
+ signal?: AbortSignal
1860
+ ): Promise<AssetBatchResponse> {
1861
+ const response = await this._httpClient.fetch(HttpMethod.Post, `${this._baseUrl}/${twinId}/assets/batch`, {
1862
+ headers: { 'Content-Type': 'application/json' },
1863
+ body: JSON.stringify({ operations }),
1864
+ signal
1865
+ });
1866
+
1867
+ if (response.ok) {
1868
+ return (await response.json()) as AssetBatchResponse;
1869
+ }
1870
+
1871
+ // The body decides, not the status: a rolled-back batch is reported as a batch error at
1872
+ // whatever status its last failing item carried, and only a body that is not a batch
1873
+ // response falls through to the status arms below.
1874
+ const batchResponse = await readBatchResponse(response);
1875
+ if (batchResponse) {
1876
+ throw new AssetBatchError(`Asset batch operation failed with status ${response.status}`, batchResponse);
1877
+ }
1878
+
1879
+ if (response.status === 400) {
1880
+ const errorBody = await readErrorBody(response);
1881
+ throw new AssetValidationError(`Asset batch validation failed: ${errorBody.detail}`, errorBody);
1882
+ }
1883
+
1884
+ if (response.status === 403) {
1885
+ const errorText = await response.text();
1886
+ throw new AssetForbiddenError(`Asset batch forbidden: ${errorText}`);
1887
+ }
1888
+
1889
+ // Reached only where the probe above could not read the body -- a Response without clone(),
1890
+ // which some test doubles are. These two statuses only ever carry a batch response, so read
1891
+ // it directly rather than losing the per-item results to the generic error below.
1892
+ if (response.status === 404 || response.status === 409) {
1893
+ const rolledBack = (await response.json()) as AssetBatchResponse;
1894
+ throw new AssetBatchError(`Asset batch operation failed with status ${response.status}`, rolledBack);
1895
+ }
1896
+
1897
+ const errorText = await response.text();
1898
+ throw new Error(`Failed to execute asset batch: ${response.status} ${response.statusText} - ${errorText}`);
1899
+ }
1900
+
1901
+ async batchGetAssets(twinId: string, ids: string[], signal?: AbortSignal): Promise<AssetBatchGetResponse> {
1902
+ const response = await this._httpClient.fetch(HttpMethod.Post, `${this._baseUrl}/${twinId}/assets/batch-get`, {
1903
+ headers: { 'Content-Type': 'application/json' },
1904
+ body: JSON.stringify({ ids }),
1905
+ signal
1906
+ });
1907
+
1908
+ if (response.ok) {
1909
+ return (await response.json()) as AssetBatchGetResponse;
1910
+ }
1911
+
1912
+ if (response.status === 400) {
1913
+ const errorBody = await readErrorBody(response);
1914
+ throw new AssetValidationError(`Asset batch-get validation failed: ${errorBody.detail}`, errorBody);
1915
+ }
1916
+
1917
+ const errorText = await response.text();
1918
+ throw new Error(`Failed to batch-get assets: ${response.status} ${response.statusText} - ${errorText}`);
1919
+ }
1920
+
1921
+ // ────────── Asset attachments ──────────
1922
+
1923
+ async listAssetAttachments(
1924
+ twinId: string,
1925
+ assetId: string,
1926
+ options?: { includeDeleted?: boolean; offset?: number; limit?: number; page?: string },
1927
+ signal?: AbortSignal
1928
+ ): Promise<PaginatedResponse<AttachmentGet>> {
1929
+ const params = new URLSearchParams();
1930
+ if (options?.includeDeleted) params.set('includeDeleted', 'true');
1931
+ if (options?.offset !== undefined) params.set('offset', String(options.offset));
1932
+ if (options?.limit !== undefined) params.set('limit', String(options.limit));
1933
+ if (options?.page) params.set('page', options.page);
1934
+
1935
+ const url = `${this._baseUrl}/${twinId}/assets/${assetId}/attachments${params.toString() ? `?${params}` : ''}`;
1936
+ const response = await this._httpClient.fetch(HttpMethod.Get, url, {
1937
+ headers: { Accept: 'application/json' },
1938
+ signal
1939
+ });
1940
+
1941
+ if (response.ok) return (await response.json()) as PaginatedResponse<AttachmentGet>;
1942
+ if (response.status === 404) throw new AssetNotFoundError(`Asset ${assetId} not found`);
1943
+ if (response.status === 400) {
1944
+ const errorBody = await readErrorBody(response);
1945
+ throw new AssetValidationError(`Attachment listing failed: ${errorBody.detail}`, errorBody);
1946
+ }
1947
+ const errorText = await response.text();
1948
+ throw new Error(`Failed to list attachments: ${response.status} ${response.statusText} - ${errorText}`);
1949
+ }
1950
+
1951
+ async getAssetAttachmentBlob(
1952
+ twinId: string,
1953
+ assetId: string,
1954
+ attachmentId: string,
1955
+ signal?: AbortSignal
1956
+ ): Promise<Response> {
1957
+ const response = await this._httpClient.fetch(
1958
+ HttpMethod.Get,
1959
+ `${this._baseUrl}/${twinId}/assets/${assetId}/attachments/${attachmentId}`,
1960
+ { signal }
1961
+ );
1962
+ if (response.ok) return response;
1963
+ if (response.status === 404) throw new AssetAttachmentNotFoundError(`Attachment ${attachmentId} not found`);
1964
+ const errorText = await response.text();
1965
+ throw new Error(`Failed to fetch attachment blob: ${response.status} ${response.statusText} - ${errorText}`);
1966
+ }
1967
+
1968
+ async createAssetAttachmentUploadSession(
1969
+ twinId: string,
1970
+ assetId: string,
1971
+ attachmentId: string,
1972
+ body: AttachmentPost,
1973
+ signal?: AbortSignal
1974
+ ): Promise<ApiCreateUploadSessionResponse> {
1975
+ const response = await this._httpClient.fetch(
1976
+ HttpMethod.Post,
1977
+ `${this._baseUrl}/${twinId}/assets/${assetId}/attachments/${attachmentId}/createAttachmentUploadSession`,
1978
+ {
1979
+ headers: { 'Content-Type': 'application/json' },
1980
+ body: JSON.stringify(body),
1981
+ signal
1982
+ }
1983
+ );
1984
+ if (response.ok) return (await response.json()) as ApiCreateUploadSessionResponse;
1985
+ if (response.status === 409) {
1986
+ const conflicted = (await response.json()) as AttachmentGet;
1987
+ throw new AssetAttachmentConflictError(
1988
+ `Attachment ${attachmentId} already exists; pick a new id for retries`,
1989
+ conflicted
1990
+ );
1991
+ }
1992
+ if (response.status === 404) throw new AssetNotFoundError(`Asset ${assetId} not found`);
1993
+ if (response.status === 400) {
1994
+ const errorBody = await readErrorBody(response);
1995
+ throw new AssetValidationError(`Upload session creation failed: ${errorBody.detail}`, errorBody);
1996
+ }
1997
+ const errorText = await response.text();
1998
+ throw new Error(`Failed to create upload session: ${response.status} ${response.statusText} - ${errorText}`);
1999
+ }
2000
+
2001
+ async uploadAssetAttachmentFile(
2002
+ session: ApiCreateUploadSessionResponse,
2003
+ file: Blob,
2004
+ signal?: AbortSignal
2005
+ ): Promise<{ token: string }> {
2006
+ // The token is encoded as a query param on the upload URL; we lift it
2007
+ // out for the caller so they can poll uploadStatus without re-parsing.
2008
+ const tokenMatch = session.uploadUrl.match(/token=([^&]+)/);
2009
+ if (!tokenMatch) {
2010
+ throw new Error(`Upload session URL is missing a token query parameter: ${session.uploadUrl}`);
2011
+ }
2012
+ const token = tokenMatch[1];
2013
+
2014
+ const chunkSize = Math.max(1, session.maxChunkSize || file.size);
2015
+ const total = file.size;
2016
+ let offset = 0;
2017
+
2018
+ // Even an empty file needs at least one PUT so the upload session
2019
+ // gets a finalize signal. uploadChunks() in the integration tests
2020
+ // unconditionally PUTs at least one chunk too.
2021
+ do {
2022
+ const end = Math.min(offset + chunkSize, total);
2023
+ const chunk = file.slice(offset, end);
2024
+ const lastByte = Math.max(0, end - 1);
2025
+ const response = await this._httpClient.fetch(HttpMethod.Put, session.uploadUrl, {
2026
+ headers: { 'Content-Range': `bytes ${offset}-${lastByte}/${total}` },
2027
+ body: chunk,
2028
+ signal
2029
+ });
2030
+ if (!response.ok) {
2031
+ const errorText = await response.text();
2032
+ throw new Error(
2033
+ `Upload chunk failed at offset ${offset}: ${response.status} ${response.statusText} - ${errorText}`
2034
+ );
2035
+ }
2036
+ offset = end;
2037
+ } while (offset < total);
2038
+
2039
+ return { token };
2040
+ }
2041
+
2042
+ async getAssetAttachmentUploadStatus(
2043
+ twinId: string,
2044
+ assetId: string,
2045
+ token: string,
2046
+ signal?: AbortSignal
2047
+ ): Promise<UploadStatusGet> {
2048
+ const response = await this._httpClient.fetch(
2049
+ HttpMethod.Get,
2050
+ `${this._baseUrl}/${twinId}/assets/${assetId}/attachments/uploadStatus/${encodeURIComponent(token)}`,
2051
+ { headers: { Accept: 'application/json' }, signal }
2052
+ );
2053
+ if (response.ok) return (await response.json()) as UploadStatusGet;
2054
+ if (response.status === 404) return { status: 'NotFound' };
2055
+ const errorText = await response.text();
2056
+ throw new Error(`Failed to get upload status: ${response.status} ${response.statusText} - ${errorText}`);
2057
+ }
2058
+
2059
+ async putAssetAttachment(
2060
+ twinId: string,
2061
+ assetId: string,
2062
+ attachmentId: string,
2063
+ body: AssetAttachmentPut,
2064
+ signal?: AbortSignal
2065
+ ): Promise<AttachmentGet> {
2066
+ const response = await this._httpClient.fetch(
2067
+ HttpMethod.Put,
2068
+ `${this._baseUrl}/${twinId}/assets/${assetId}/attachments/${attachmentId}`,
2069
+ {
2070
+ headers: { 'Content-Type': 'application/json' },
2071
+ body: JSON.stringify(body),
2072
+ signal
2073
+ }
2074
+ );
2075
+ if (response.ok) return (await response.json()) as AttachmentGet;
2076
+ if (response.status === 409) {
2077
+ const conflicted = (await response.json()) as AttachmentGet;
2078
+ throw new AssetAttachmentConflictError('Attachment has been modified by another user', conflicted);
2079
+ }
2080
+ if (response.status === 404) throw new AssetAttachmentNotFoundError(`Attachment ${attachmentId} not found`);
2081
+ if (response.status === 400) {
2082
+ const errorBody = await readErrorBody(response);
2083
+ throw new AssetValidationError(`Attachment update failed: ${errorBody.detail}`, errorBody);
2084
+ }
2085
+ const errorText = await response.text();
2086
+ throw new Error(`Failed to update attachment: ${response.status} ${response.statusText} - ${errorText}`);
2087
+ }
2088
+
2089
+ async deleteAssetAttachment(
2090
+ twinId: string,
2091
+ assetId: string,
2092
+ attachmentId: string,
2093
+ etag: string,
2094
+ signal?: AbortSignal
2095
+ ): Promise<AttachmentGet> {
2096
+ const response = await this._httpClient.fetch(
2097
+ HttpMethod.Delete,
2098
+ `${this._baseUrl}/${twinId}/assets/${assetId}/attachments/${attachmentId}`,
2099
+ {
2100
+ headers: { 'If-Match': etag },
2101
+ signal
2102
+ }
2103
+ );
2104
+ if (response.ok) return (await response.json()) as AttachmentGet;
2105
+ if (response.status === 409) {
2106
+ const conflicted = (await response.json()) as AttachmentGet;
2107
+ throw new AssetAttachmentConflictError('Attachment has been modified by another user', conflicted);
2108
+ }
2109
+ if (response.status === 404) throw new AssetAttachmentNotFoundError(`Attachment ${attachmentId} not found`);
2110
+ const errorText = await response.text();
2111
+ throw new Error(`Failed to delete attachment: ${response.status} ${response.statusText} - ${errorText}`);
2112
+ }
2113
+
2114
+ async restoreAssetAttachment(
2115
+ twinId: string,
2116
+ assetId: string,
2117
+ attachmentId: string,
2118
+ etag: string,
2119
+ signal?: AbortSignal
2120
+ ): Promise<AttachmentGet> {
2121
+ const response = await this._httpClient.fetch(
2122
+ HttpMethod.Put,
2123
+ `${this._baseUrl}/${twinId}/assets/${assetId}/attachments/${attachmentId}/restore`,
2124
+ {
2125
+ headers: { 'Content-Type': 'application/json' },
2126
+ body: JSON.stringify({ etag }),
2127
+ signal
2128
+ }
2129
+ );
2130
+ if (response.ok) return (await response.json()) as AttachmentGet;
2131
+ if (response.status === 409) {
2132
+ const conflicted = (await response.json()) as AttachmentGet;
2133
+ throw new AssetAttachmentConflictError('Attachment has been modified by another user', conflicted);
2134
+ }
2135
+ if (response.status === 404) throw new AssetAttachmentNotFoundError(`Attachment ${attachmentId} not found`);
2136
+ const errorText = await response.text();
2137
+ throw new Error(`Failed to restore attachment: ${response.status} ${response.statusText} - ${errorText}`);
2138
+ }
2139
+
2140
+ async listTwinFiles(
2141
+ twinId: string,
2142
+ options: TwinFileListOptions = {},
2143
+ signal?: AbortSignal
2144
+ ): Promise<PaginatedResponse<TwinFileGet>> {
2145
+ const { includeDeleted = false, checkedIn = false, version, page = undefined, limit = 100 } = options;
2146
+
2147
+ if (version !== undefined && !checkedIn) {
2148
+ throw new Error('The "version" option can only be used when "checkedIn" is true.');
2149
+ }
2150
+
2151
+ const params = new URLSearchParams();
2152
+ if (includeDeleted) params.set('includeDeleted', 'true');
2153
+ if (checkedIn) params.set('checkedIn', 'true');
2154
+ if (version !== undefined) params.set('version', version.toString());
2155
+ if (page !== undefined) params.set('page', page);
2156
+ if (limit !== 100) params.set('limit', limit.toString());
2157
+
2158
+ const url = params.toString()
2159
+ ? `${this._baseUrl}/${twinId}/files?${params}`
2160
+ : `${this._baseUrl}/${twinId}/files`;
2161
+
2162
+ const response = await this._httpClient.fetch(HttpMethod.Get, url, {
2163
+ headers: { Accept: 'application/json' },
2164
+ signal
2165
+ });
2166
+
2167
+ if (response.ok) {
2168
+ return (await response.json()) as PaginatedResponse<TwinFileGet>;
2169
+ }
2170
+
2171
+ if (response.status === 400) {
2172
+ const errorBody = await readErrorBody(response);
2173
+ throw new TwinFileValidationError(`Twin file listing validation failed: ${errorBody.detail}`, errorBody);
2174
+ }
2175
+
2176
+ const errorText = await response.text();
2177
+ throw new Error(`Failed to list twin files: ${response.status} ${response.statusText} - ${errorText}`);
2178
+ }
2179
+
2180
+ async getTwinFile(twinId: string, fileId: string, signal?: AbortSignal): Promise<TwinFileGet> {
2181
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/files/${fileId}`, {
2182
+ headers: { Accept: 'application/json' },
2183
+ signal
2184
+ });
2185
+
2186
+ if (response.ok) {
2187
+ return (await response.json()) as TwinFileGet;
2188
+ }
2189
+
2190
+ if (response.status === 404) {
2191
+ throw new TwinFileNotFoundError(`Twin file ${fileId} not found`);
2192
+ }
2193
+
2194
+ if (response.status === 400) {
2195
+ const errorText = await response.text();
2196
+ throw new TwinFileValidationError(`Twin file validation failed: ${errorText}`);
2197
+ }
2198
+
2199
+ const errorText = await response.text();
2200
+ throw new Error(`Failed to get twin file: ${response.status} ${response.statusText} - ${errorText}`);
2201
+ }
2202
+
2203
+ async createOrUpdateTwinFile(
2204
+ twinId: string,
2205
+ fileId: string,
2206
+ twinFile: TwinFilePut,
2207
+ signal?: AbortSignal
2208
+ ): Promise<TwinFileGet> {
2209
+ const response = await this._httpClient.fetch(HttpMethod.Put, `${this._baseUrl}/${twinId}/files/${fileId}`, {
2210
+ headers: { 'Content-Type': 'application/json' },
2211
+ body: JSON.stringify(twinFile),
2212
+ signal
2213
+ });
2214
+
2215
+ if (response.ok || response.status === 201) {
2216
+ return (await response.json()) as TwinFileGet;
2217
+ }
2218
+
2219
+ if (response.status === 400) {
2220
+ const errorBody = await readErrorBody(response);
2221
+ throw new TwinFileValidationError(`Twin file validation failed`, errorBody);
2222
+ }
2223
+
2224
+ if (response.status === 409) {
2225
+ const conflictedFile = (await response.json()) as TwinFileGet;
2226
+ throw new TwinFileConflictError('Twin file has been modified by another user', conflictedFile);
2227
+ }
2228
+
2229
+ const errorText = await response.text();
2230
+ throw new Error(`Failed to create/update twin file: ${response.status} ${response.statusText} - ${errorText}`);
2231
+ }
2232
+
2233
+ async deleteTwinFile(
2234
+ twinId: string,
2235
+ fileId: string,
2236
+ deleteRequest: TwinFileDelete,
2237
+ signal?: AbortSignal
2238
+ ): Promise<TwinFileGet> {
2239
+ const response = await this._httpClient.fetch(HttpMethod.Delete, `${this._baseUrl}/${twinId}/files/${fileId}`, {
2240
+ headers: { 'Content-Type': 'application/json' },
2241
+ body: JSON.stringify(deleteRequest),
2242
+ signal
2243
+ });
2244
+
2245
+ if (response.ok) {
2246
+ return (await response.json()) as TwinFileGet;
2247
+ }
2248
+
2249
+ if (response.status === 404) {
2250
+ throw new TwinFileNotFoundError(`Twin file ${fileId} not found`);
2251
+ }
2252
+
2253
+ if (response.status === 409) {
2254
+ const conflictedFile = (await response.json()) as TwinFileGet;
2255
+ throw new TwinFileConflictError('Twin file has been modified by another user', conflictedFile);
2256
+ }
2257
+
2258
+ if (response.status === 400) {
2259
+ const errorText = await response.text();
2260
+ throw new TwinFileValidationError(`Twin file validation failed: ${errorText}`);
2261
+ }
2262
+
2263
+ const errorText = await response.text();
2264
+ throw new Error(`Failed to delete twin file: ${response.status} ${response.statusText} - ${errorText}`);
2265
+ }
2266
+
2267
+ async restoreTwinFile(twinId: string, fileId: string, etag: string, signal?: AbortSignal): Promise<TwinFileGet> {
2268
+ const response = await this._httpClient.fetch(
2269
+ HttpMethod.Put,
2270
+ `${this._baseUrl}/${twinId}/files/${fileId}/restore`,
2271
+ {
2272
+ headers: { 'Content-Type': 'application/json' },
2273
+ body: JSON.stringify({ etag }),
2274
+ signal
2275
+ }
2276
+ );
2277
+
2278
+ if (response.ok) {
2279
+ return (await response.json()) as TwinFileGet;
2280
+ }
2281
+
2282
+ if (response.status === 404) {
2283
+ throw new TwinFileNotFoundError(`Twin file ${fileId} not found`);
2284
+ }
2285
+
2286
+ if (response.status === 409) {
2287
+ const conflictedFile = (await response.json()) as TwinFileGet;
2288
+ throw new TwinFileConflictError('Twin file has been modified by another user', conflictedFile);
2289
+ }
2290
+
2291
+ if (response.status === 400) {
2292
+ const errorText = await response.text();
2293
+ throw new TwinFileValidationError(`Twin file validation failed: ${errorText}`);
2294
+ }
2295
+
2296
+ const errorText = await response.text();
2297
+ throw new Error(`Failed to restore twin file: ${response.status} ${response.statusText} - ${errorText}`);
2298
+ }
2299
+
2300
+ async getTwinFileContent(twinId: string, fileId: string, signal?: AbortSignal): Promise<Blob> {
2301
+ const response = await this._httpClient.fetch(
2302
+ HttpMethod.Get,
2303
+ `${this._baseUrl}/${twinId}/files/${fileId}/content`,
2304
+ { signal }
2305
+ );
2306
+
2307
+ if (response.ok) {
2308
+ return await response.blob();
2309
+ }
2310
+
2311
+ if (response.status === 404) {
2312
+ throw new TwinFileNotFoundError(`Twin file ${fileId} not found`);
2313
+ }
2314
+
2315
+ const errorText = await response.text();
2316
+ throw new Error(`Failed to get twin file content: ${response.status} ${response.statusText} - ${errorText}`);
2317
+ }
2318
+
2319
+ async getTwinFileVersion(
2320
+ twinId: string,
2321
+ fileId: string,
2322
+ version: number,
2323
+ signal?: AbortSignal
2324
+ ): Promise<TwinFileGet> {
2325
+ const response = await this._httpClient.fetch(
2326
+ HttpMethod.Get,
2327
+ `${this._baseUrl}/${twinId}/files/${fileId}/versions/${version}`,
2328
+ { signal }
2329
+ );
2330
+
2331
+ if (response.ok) {
2332
+ return (await response.json()) as TwinFileGet;
2333
+ }
2334
+
2335
+ if (response.status === 404) {
2336
+ throw new TwinFileNotFoundError(`Twin file ${fileId} version ${version} not found`);
2337
+ }
2338
+
2339
+ const errorText = await response.text();
2340
+ throw new Error(`Failed to get twin file version: ${response.status} ${response.statusText} - ${errorText}`);
2341
+ }
2342
+
2343
+ async listTwinFileVersions(
2344
+ twinId: string,
2345
+ fileId: string,
2346
+ options?: TwinFileVersionListOptions,
2347
+ signal?: AbortSignal
2348
+ ): Promise<PaginatedResponse<TwinFileGet>> {
2349
+ const params = new URLSearchParams();
2350
+ if (options?.includeDeleted !== undefined) params.append('includeDeleted', String(options.includeDeleted));
2351
+ if (options?.offset !== undefined) params.append('offset', String(options.offset));
2352
+ if (options?.limit !== undefined) params.append('limit', String(options.limit));
2353
+
2354
+ const response = await this._httpClient.fetch(
2355
+ HttpMethod.Get,
2356
+ `${this._baseUrl}/${twinId}/files/${fileId}/versions?${params.toString()}`,
2357
+ { signal }
2358
+ );
2359
+
2360
+ if (response.ok) {
2361
+ return (await response.json()) as PaginatedResponse<TwinFileGet>;
2362
+ }
2363
+
2364
+ if (response.status === 400) {
2365
+ const errorBody = await readErrorBody(response);
2366
+ throw new TwinFileValidationError(
2367
+ `Twin file version listing validation failed: ${errorBody.detail}`,
2368
+ errorBody
2369
+ );
2370
+ }
2371
+
2372
+ const errorText = await response.text();
2373
+ throw new Error(`Failed to list twin file versions: ${response.status} ${response.statusText} - ${errorText}`);
2374
+ }
2375
+
2376
+ async getTwinFileVersionContent(
2377
+ twinId: string,
2378
+ fileId: string,
2379
+ version: number,
2380
+ signal?: AbortSignal
2381
+ ): Promise<Blob> {
2382
+ const response = await this._httpClient.fetch(
2383
+ HttpMethod.Get,
2384
+ `${this._baseUrl}/${twinId}/files/${fileId}/versions/${version}/content`,
2385
+ { signal }
2386
+ );
2387
+
2388
+ if (response.ok) {
2389
+ return await response.blob();
2390
+ }
2391
+
2392
+ if (response.status === 404) {
2393
+ throw new TwinFileNotFoundError(`Twin file ${fileId} version ${version} content not found`);
2394
+ }
2395
+
2396
+ const errorText = await response.text();
2397
+ throw new Error(
2398
+ `Failed to get twin file version content: ${response.status} ${response.statusText} - ${errorText}`
2399
+ );
2400
+ }
2401
+
2402
+ async createOrUpdateTwinRaw(twinId: string, twin: unknown, signal?: AbortSignal): Promise<Response> {
2403
+ return await this._httpClient.fetch(HttpMethod.Put, `${this._baseUrl}/${twinId}`, {
2404
+ headers: { 'Content-Type': 'application/json' },
2405
+ body: JSON.stringify(twin),
2406
+ signal
2407
+ });
2408
+ }
2409
+
2410
+ async createOrUpdateTwinFileRaw(
2411
+ twinId: string,
2412
+ fileId: string,
2413
+ twinFile: unknown,
2414
+ signal?: AbortSignal
2415
+ ): Promise<Response> {
2416
+ return await this._httpClient.fetch(HttpMethod.Put, `${this._baseUrl}/${twinId}/files/${fileId}`, {
2417
+ headers: { 'Content-Type': 'application/json' },
2418
+ body: JSON.stringify(twinFile),
2419
+ signal
2420
+ });
2421
+ }
2422
+
2423
+ // ---------- Asset categories ----------
2424
+
2425
+ async getMyPermissions(signal?: AbortSignal): Promise<MyPermissions> {
2426
+ const url = `${this._baseUrl}/permissions/me`;
2427
+ const response = await this._httpClient.fetch(HttpMethod.Get, url, {
2428
+ headers: { Accept: 'application/json' },
2429
+ signal
2430
+ });
2431
+ if (response.ok) {
2432
+ return (await response.json()) as MyPermissions;
2433
+ }
2434
+ // 401: no valid auth context. Throw so consumers can redirect to
2435
+ // login / refresh tokens rather than mistake an expired session for
2436
+ // "logged-in user with no grants."
2437
+ if (response.status === 401) {
2438
+ throw new TwinUnauthenticatedError('Not authenticated');
2439
+ }
2440
+ // 403: authenticated but no Twin-service capabilities. Surface as an
2441
+ // empty array so consumers can treat it like a logged-in user with
2442
+ // zero grants.
2443
+ if (response.status === 403) {
2444
+ return { permissions: [] };
2445
+ }
2446
+ throw new Error(`Failed to fetch permissions: ${response.status} ${response.statusText}`);
2447
+ }
2448
+
2449
+ // ---------- Twin-scoped permissions (TWINS-2982) ----------
2450
+
2451
+ async getMyTwinPermissions(twinId: string, signal?: AbortSignal): Promise<MyTwinPermissions> {
2452
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/permissions/me`, {
2453
+ headers: { Accept: 'application/json' },
2454
+ signal
2455
+ });
2456
+
2457
+ if (response.ok) return (await response.json()) as MyTwinPermissions;
2458
+ return this._failTwinPermissionsRequest(response, 'read the capabilities on the twin', twinId);
2459
+ }
2460
+
2461
+ async listTwinPermissions(
2462
+ twinId: string,
2463
+ options?: { page?: string; limit?: number },
2464
+ signal?: AbortSignal
2465
+ ): Promise<PaginatedResponse<TwinPrincipalPermissionsGet>> {
2466
+ const params = new URLSearchParams();
2467
+ if (options?.page !== undefined) params.set('page', options.page);
2468
+ if (options?.limit !== undefined) params.set('limit', options.limit.toString());
2469
+
2470
+ const query = params.toString();
2471
+ const response = await this._httpClient.fetch(
2472
+ HttpMethod.Get,
2473
+ `${this._baseUrl}/${twinId}/permissions${query ? `?${query}` : ''}`,
2474
+ { headers: { Accept: 'application/json' }, signal }
2475
+ );
2476
+
2477
+ if (response.ok) return (await response.json()) as PaginatedResponse<TwinPrincipalPermissionsGet>;
2478
+ return this._failTwinPermissionsRequest(response, 'list the twin permissions', twinId);
2479
+ }
2480
+
2481
+ async listAllTwinPermissions(twinId: string, signal?: AbortSignal): Promise<TwinPrincipalPermissionsGet[]> {
2482
+ const limit = 1000;
2483
+ const entries: TwinPrincipalPermissionsGet[] = [];
2484
+
2485
+ // Paged on a short page rather than on `nextPage`, which the service emits even for the
2486
+ // last page — the same reason `listAllTwins` pages this way.
2487
+ let page: string | undefined;
2488
+ let received: number;
2489
+ do {
2490
+ const response = await this.listTwinPermissions(twinId, { page, limit }, signal);
2491
+ entries.push(...response.data);
2492
+ received = response.data.length;
2493
+ page = response.pagination.nextPage;
2494
+ } while (received >= limit);
2495
+
2496
+ return entries;
2497
+ }
2498
+
2499
+ async setTwinPermissions(
2500
+ twinId: string,
2501
+ principalId: string,
2502
+ body: TwinPermissionsPut,
2503
+ signal?: AbortSignal
2504
+ ): Promise<TwinPrincipalPermissionsGet> {
2505
+ const response = await this._httpClient.fetch(
2506
+ HttpMethod.Put,
2507
+ `${this._baseUrl}/${twinId}/permissions/${principalId}`,
2508
+ {
2509
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
2510
+ body: JSON.stringify(body),
2511
+ signal
2512
+ }
2513
+ );
2514
+
2515
+ if (response.ok) return (await response.json()) as TwinPrincipalPermissionsGet;
2516
+ return this._failTwinPermissionsRequest(response, 'set the twin permissions', twinId, {
2517
+ id: principalId,
2518
+ label: 'Principal'
2519
+ });
2520
+ }
2521
+
2522
+ async deleteTwinPermissions(
2523
+ twinId: string,
2524
+ principalId: string,
2525
+ etag: string,
2526
+ signal?: AbortSignal
2527
+ ): Promise<TwinPrincipalPermissionsGet> {
2528
+ const response = await this._httpClient.fetch(
2529
+ HttpMethod.Delete,
2530
+ `${this._baseUrl}/${twinId}/permissions/${principalId}`,
2531
+ {
2532
+ // The etag travels in the header, which is the transport the surface prefers and
2533
+ // the one that wins when both are present. It is repeated in the body — the other
2534
+ // carrier ADR-0008 documents, and the one the endpoint binds — so that the request
2535
+ // still names an etag if a proxy strips the header, or drops the body of a DELETE.
2536
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'If-Match': etag },
2537
+ body: JSON.stringify({ etag }),
2538
+ signal
2539
+ }
2540
+ );
2541
+
2542
+ if (response.ok) return (await response.json()) as TwinPrincipalPermissionsGet;
2543
+ return this._failTwinPermissionsRequest(response, 'revoke the twin permissions', twinId, {
2544
+ id: principalId,
2545
+ label: 'Principal'
2546
+ });
2547
+ }
2548
+
2549
+ async listTwinGroups(twinId: string, signal?: AbortSignal): Promise<TwinGroupsGet> {
2550
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/groups`, {
2551
+ headers: { Accept: 'application/json' },
2552
+ signal
2553
+ });
2554
+
2555
+ if (response.ok) return (await response.json()) as TwinGroupsGet;
2556
+ return this._failTwinPermissionsRequest(response, "list the twin's groups", twinId);
2557
+ }
2558
+
2559
+ async getTwinGroup(twinId: string, groupId: string, signal?: AbortSignal): Promise<TwinGroupGet> {
2560
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/${twinId}/groups/${groupId}`, {
2561
+ headers: { Accept: 'application/json' },
2562
+ signal
2563
+ });
2564
+
2565
+ if (response.ok) return (await response.json()) as TwinGroupGet;
2566
+ return this._failTwinPermissionsRequest(response, "get the twin's group", twinId, {
2567
+ id: groupId,
2568
+ label: 'Group'
2569
+ });
2570
+ }
2571
+
2572
+ // ---------- Asset-domain permissions ----------
2573
+
2574
+ async listAssetPermissions(
2575
+ options?: { page?: string; limit?: number },
2576
+ signal?: AbortSignal
2577
+ ): Promise<PaginatedResponse<AssetPrincipalPermissionsGet>> {
2578
+ const params = new URLSearchParams();
2579
+ if (options?.page !== undefined) params.set('page', options.page);
2580
+ if (options?.limit !== undefined) params.set('limit', options.limit.toString());
2581
+
2582
+ const query = params.toString();
2583
+ const response = await this._httpClient.fetch(
2584
+ HttpMethod.Get,
2585
+ `${this._baseUrl}/permissions${query ? `?${query}` : ''}`,
2586
+ { headers: { Accept: 'application/json' }, signal }
2587
+ );
2588
+
2589
+ if (response.ok) return (await response.json()) as PaginatedResponse<AssetPrincipalPermissionsGet>;
2590
+ return this._failAssetPermissionsRequest(response, 'list the asset permissions');
2591
+ }
2592
+
2593
+ async listAllAssetPermissions(signal?: AbortSignal): Promise<AssetPrincipalPermissionsGet[]> {
2594
+ const limit = 1000;
2595
+ const entries: AssetPrincipalPermissionsGet[] = [];
2596
+
2597
+ // Paged on a short page rather than on `nextPage`, which the service emits even for the
2598
+ // last page — the same reason `listAllTwinPermissions` pages this way.
2599
+ let page: string | undefined;
2600
+ let received: number;
2601
+ do {
2602
+ const response = await this.listAssetPermissions({ page, limit }, signal);
2603
+ entries.push(...response.data);
2604
+ received = response.data.length;
2605
+ page = response.pagination.nextPage;
2606
+ } while (received >= limit);
2607
+
2608
+ return entries;
2609
+ }
2610
+
2611
+ async setAssetPermissions(
2612
+ principalId: string,
2613
+ body: AssetPermissionsPut,
2614
+ signal?: AbortSignal
2615
+ ): Promise<AssetPrincipalPermissionsGet> {
2616
+ const response = await this._httpClient.fetch(HttpMethod.Put, `${this._baseUrl}/permissions/${principalId}`, {
2617
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
2618
+ body: JSON.stringify(body),
2619
+ signal
2620
+ });
2621
+
2622
+ if (response.ok) return (await response.json()) as AssetPrincipalPermissionsGet;
2623
+ return this._failAssetPermissionsRequest(response, 'set the asset permissions', {
2624
+ id: principalId,
2625
+ label: 'Principal'
2626
+ });
2627
+ }
2628
+
2629
+ async deleteAssetPermissions(
2630
+ principalId: string,
2631
+ etag: string,
2632
+ signal?: AbortSignal
2633
+ ): Promise<AssetPrincipalPermissionsGet> {
2634
+ const response = await this._httpClient.fetch(
2635
+ HttpMethod.Delete,
2636
+ `${this._baseUrl}/permissions/${principalId}`,
2637
+ {
2638
+ // The etag travels in the header, which is the transport the surface prefers and the
2639
+ // one that wins when both are present. It is repeated in the body — the other
2640
+ // carrier the endpoint binds — so the request still names an etag if a proxy strips
2641
+ // the header, or drops the body of a DELETE.
2642
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'If-Match': etag },
2643
+ body: JSON.stringify({ etag }),
2644
+ signal
2645
+ }
2646
+ );
2647
+
2648
+ if (response.ok) return (await response.json()) as AssetPrincipalPermissionsGet;
2649
+ return this._failAssetPermissionsRequest(response, 'revoke the asset permissions', {
2650
+ id: principalId,
2651
+ label: 'Principal'
2652
+ });
2653
+ }
2654
+
2655
+ async listAssetGroups(signal?: AbortSignal): Promise<AssetGroupsGet> {
2656
+ const response = await this._httpClient.fetch(HttpMethod.Get, `${this._baseUrl}/groups`, {
2657
+ headers: { Accept: 'application/json' },
2658
+ signal
2659
+ });
2660
+
2661
+ if (response.ok) return (await response.json()) as AssetGroupsGet;
2662
+ return this._failAssetPermissionsRequest(response, "list the asset domain's default groups");
2663
+ }
2664
+
2665
+ async getAssetGroup(name: string, signal?: AbortSignal): Promise<AssetGroupGet> {
2666
+ const response = await this._httpClient.fetch(
2667
+ HttpMethod.Get,
2668
+ `${this._baseUrl}/groups/${encodeURIComponent(name)}`,
2669
+ { headers: { Accept: 'application/json' }, signal }
2670
+ );
2671
+
2672
+ if (response.ok) return (await response.json()) as AssetGroupGet;
2673
+ return this._failAssetPermissionsRequest(response, "get the asset domain's default group", {
2674
+ id: name,
2675
+ label: 'Group'
2676
+ });
2677
+ }
2678
+
2679
+ /**
2680
+ * Maps a refusal from the customer-wide asset permission surface onto the typed error its
2681
+ * status and cause name. The twin-scoped counterpart of this cannot be reused: its 404s are
2682
+ * about a twin, and this surface has none — a 404 naming no principal means the *name* was
2683
+ * unknown, not that a resource was missing.
2684
+ *
2685
+ * A 404 is dispatched on `item.kind`, which is what separates "that group has not been seeded
2686
+ * for this customer yet", which is worth retrying, from "no such group name", which is not.
2687
+ */
2688
+ private async _failAssetPermissionsRequest(
2689
+ response: Response,
2690
+ action: string,
2691
+ principal?: { id: string; label: string }
2692
+ ): Promise<never> {
2693
+ if (response.status === 401) {
2694
+ throw new TwinUnauthenticatedError('Not authenticated');
2695
+ }
2696
+
2697
+ const errorBody = await readErrorBody(response);
2698
+ switch (response.status) {
2699
+ case 400:
2700
+ throw new TwinValidationError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
2701
+ case 403:
2702
+ // The message is the server's own, never a reason invented here: a Twin service that
2703
+ // could not evaluate the request answers a bodiless 403, and `errorBody.cause` is
2704
+ // what separates that from a genuine `InsufficientPermissions`.
2705
+ throw new TwinPermissionsForbiddenError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
2706
+ case 404:
2707
+ if (errorBody.item?.kind === 'Principal' && principal !== undefined) {
2708
+ throw new TwinPrincipalNotFoundError(
2709
+ `${principal.label} ${principal.id} not found on the asset domain`,
2710
+ errorBody
2711
+ );
2712
+ }
2713
+ throw new TwinNotFoundError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
2714
+ case 409:
2715
+ throw new AssetPermissionsConflictError(
2716
+ `Failed to ${action}: ${errorBody.detail}`,
2717
+ errorBody as AssetPermissionsConflictErrorBody
2718
+ );
2719
+ case 503:
2720
+ throw new TwinTemporarilyUnavailableError(
2721
+ `Failed to ${action}: ${errorBody.detail}`,
2722
+ errorBody,
2723
+ retryAfterSeconds(response)
2724
+ );
2725
+ }
2726
+
2727
+ // A 500 can also mean the Permission service failed: the Twin service answers 503 when the
2728
+ // call threw, but 500 when it returned an upstream failure. Only the 503 half is classified
2729
+ // as retryable here, because the 500 arrives as a ProblemDetails payload with no cause to
2730
+ // recognise it by.
2731
+ throw new Error(`Failed to ${action}: ${response.status} ${response.statusText} - ${errorBody.detail}`);
2732
+ }
2733
+
2734
+ /**
2735
+ * Maps a refusal from the twin-scoped permission surface onto the typed error its status
2736
+ * and cause name. Shared by all six methods because the surface answers them uniformly:
2737
+ * the status is decided by the request's shape and the caller's grants, not by the route.
2738
+ *
2739
+ * A 404 is dispatched on `item.kind`, which is the only thing separating "no such twin, or
2740
+ * none you may see" from "the principal you named is not there" — the two arrive on the
2741
+ * same status from the same route.
2742
+ */
2743
+ private async _failTwinPermissionsRequest(
2744
+ response: Response,
2745
+ action: string,
2746
+ twinId: string,
2747
+ principal?: { id: string; label: string }
2748
+ ): Promise<never> {
2749
+ if (response.status === 401) {
2750
+ throw new TwinUnauthenticatedError('Not authenticated');
2751
+ }
2752
+
2753
+ const errorBody = await readErrorBody(response);
2754
+ switch (response.status) {
2755
+ case 400:
2756
+ throw new TwinValidationError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
2757
+ case 403:
2758
+ // The message is the server's own, never a reason invented here: a 403 does not
2759
+ // always mean "you lack ManageTwinPermissions". `/permissions/me` has no capability
2760
+ // gate at all, and a Twin service that could not evaluate the request answers a
2761
+ // bodiless 403 — `errorBody.cause` is what separates those, so it travels with it.
2762
+ throw new TwinPermissionsForbiddenError(`Failed to ${action}: ${errorBody.detail}`, errorBody);
2763
+ case 404:
2764
+ if (errorBody.item?.kind === 'Principal' && principal !== undefined) {
2765
+ throw new TwinPrincipalNotFoundError(
2766
+ `${principal.label} ${principal.id} not found on twin ${twinId}`,
2767
+ errorBody
2768
+ );
2769
+ }
2770
+ throw new TwinNotFoundError(`Twin ${twinId} not found`, errorBody);
2771
+ case 409:
2772
+ throw new TwinPermissionsConflictError(
2773
+ `Failed to ${action}: ${errorBody.detail}`,
2774
+ errorBody as TwinPermissionsConflictErrorBody
2775
+ );
2776
+ case 503:
2777
+ throw new TwinTemporarilyUnavailableError(
2778
+ `Failed to ${action}: ${errorBody.detail}`,
2779
+ errorBody,
2780
+ retryAfterSeconds(response)
2781
+ );
2782
+ }
2783
+
2784
+ // Note that a 500 can also mean the Permission service failed: the Twin service answers 503
2785
+ // when the call threw, but 500 when it returned an upstream failure. Only the 503 half is
2786
+ // classified as retryable here, because the 500 arrives as a ProblemDetails payload with no
2787
+ // cause to recognise it by.
2788
+ throw new Error(`Failed to ${action}: ${response.status} ${response.statusText} - ${errorBody.detail}`);
2789
+ }
2790
+
2791
+ async getAssetTypeMetaSchema(signal?: AbortSignal): Promise<Record<string, unknown>> {
2792
+ const url = `${this._assetTypeCatalogUrl}/meta-schema`;
2793
+ const response = await this._httpClient.fetch(HttpMethod.Get, url, {
2794
+ headers: { Accept: 'application/schema+json, application/json' },
2795
+ signal
2796
+ });
2797
+ if (!response.ok) {
2798
+ throw new Error(`Failed to fetch meta-schema: ${response.status} ${response.statusText}`);
2799
+ }
2800
+ return (await response.json()) as Record<string, unknown>;
2801
+ }
2802
+
2803
+ async listAssetCategories(
2804
+ options?: { page?: string; limit?: number; includeDeleted?: boolean },
2805
+ signal?: AbortSignal
2806
+ ): Promise<PaginatedResponse<AssetCategoryGet>> {
2807
+ const params = new URLSearchParams();
2808
+ if (options?.page !== undefined) params.set('page', options.page);
2809
+ if (options?.limit !== undefined) params.set('limit', options.limit.toString());
2810
+ if (options?.includeDeleted) params.set('includeDeleted', 'true');
2811
+
2812
+ const url = params.toString()
2813
+ ? `${this._assetTypeCatalogUrl}/categories?${params}`
2814
+ : `${this._assetTypeCatalogUrl}/categories`;
2815
+ const response = await this._httpClient.fetch(HttpMethod.Get, url, {
2816
+ headers: { Accept: 'application/json' },
2817
+ signal
2818
+ });
2819
+
2820
+ if (response.ok) return (await response.json()) as PaginatedResponse<AssetCategoryGet>;
2821
+ if (response.status === 403) throw new AssetCategoryForbiddenError('Asset category listing forbidden');
2822
+ if (response.status === 400) {
2823
+ const errorBody = await readErrorBody(response);
2824
+ throw new AssetCategoryValidationError(`Asset category listing failed: ${errorBody.detail}`, errorBody);
2825
+ }
2826
+ throw new Error(`Failed to list asset categories: ${response.status} ${response.statusText}`);
2827
+ }
2828
+
2829
+ async getAssetCategory(categoryId: string, signal?: AbortSignal): Promise<AssetCategoryGet> {
2830
+ const response = await this._httpClient.fetch(
2831
+ HttpMethod.Get,
2832
+ `${this._assetTypeCatalogUrl}/categories/${categoryId}`,
2833
+ {
2834
+ headers: { Accept: 'application/json' },
2835
+ signal
2836
+ }
2837
+ );
2838
+ if (response.ok) return (await response.json()) as AssetCategoryGet;
2839
+ if (response.status === 403)
2840
+ throw new AssetCategoryForbiddenError(`Asset category ${categoryId} read forbidden`);
2841
+ if (response.status === 404) throw new AssetCategoryNotFoundError(`Asset category ${categoryId} not found`);
2842
+ throw new Error(`Failed to get asset category: ${response.status} ${response.statusText}`);
2843
+ }
2844
+
2845
+ async putAssetCategory(
2846
+ categoryId: string,
2847
+ body: AssetCategoryPut,
2848
+ signal?: AbortSignal
2849
+ ): Promise<AssetCategoryGet> {
2850
+ const response = await this._httpClient.fetch(
2851
+ HttpMethod.Put,
2852
+ `${this._assetTypeCatalogUrl}/categories/${categoryId}`,
2853
+ {
2854
+ headers: { 'Content-Type': 'application/json' },
2855
+ body: JSON.stringify(body),
2856
+ signal
2857
+ }
2858
+ );
2859
+ if (response.ok) return (await response.json()) as AssetCategoryGet;
2860
+ if (response.status === 403) throw new AssetCategoryForbiddenError('Asset category upsert forbidden');
2861
+ if (response.status === 409) {
2862
+ const current = (await response.json()) as AssetCategoryGet;
2863
+ throw new AssetCategoryConflictError('Asset category was modified by another user', current);
2864
+ }
2865
+ if (response.status === 400) {
2866
+ const errorBody = await readErrorBody(response);
2867
+ throw new AssetCategoryValidationError(`Asset category validation failed: ${errorBody.detail}`, errorBody);
2868
+ }
2869
+ const errorText = await response.text();
2870
+ throw new Error(`Failed to upsert asset category: ${response.status} ${response.statusText} - ${errorText}`);
2871
+ }
2872
+
2873
+ async deleteAssetCategory(categoryId: string, etag: string, signal?: AbortSignal): Promise<AssetCategoryGet> {
2874
+ return this._categoryDeleteOrRestore(categoryId, etag, /*isRestore*/ false, signal);
2875
+ }
2876
+
2877
+ async restoreAssetCategory(categoryId: string, etag: string, signal?: AbortSignal): Promise<AssetCategoryGet> {
2878
+ return this._categoryDeleteOrRestore(categoryId, etag, /*isRestore*/ true, signal);
2879
+ }
2880
+
2881
+ private async _categoryDeleteOrRestore(
2882
+ categoryId: string,
2883
+ etag: string,
2884
+ isRestore: boolean,
2885
+ signal?: AbortSignal
2886
+ ): Promise<AssetCategoryGet> {
2887
+ const url = isRestore
2888
+ ? `${this._assetTypeCatalogUrl}/categories/${categoryId}/restore`
2889
+ : `${this._assetTypeCatalogUrl}/categories/${categoryId}`;
2890
+ const method = isRestore ? HttpMethod.Put : HttpMethod.Delete;
2891
+ const response = await this._httpClient.fetch(method, url, {
2892
+ headers: { 'Content-Type': 'application/json' },
2893
+ body: JSON.stringify({ etag }),
2894
+ signal
2895
+ });
2896
+ if (response.ok) return (await response.json()) as AssetCategoryGet;
2897
+ if (response.status === 403) {
2898
+ const op = isRestore ? 'restore' : 'delete';
2899
+ throw new AssetCategoryForbiddenError(`Asset category ${op} forbidden`);
2900
+ }
2901
+ if (response.status === 409) {
2902
+ const current = (await response.json()) as AssetCategoryGet;
2903
+ throw new AssetCategoryConflictError('Asset category was modified by another user', current);
2904
+ }
2905
+ if (response.status === 404) throw new AssetCategoryNotFoundError(`Asset category ${categoryId} not found`);
2906
+ const errorText = await response.text();
2907
+ const op = isRestore ? 'restore' : 'delete';
2908
+ throw new Error(`Failed to ${op} asset category: ${response.status} ${response.statusText} - ${errorText}`);
2909
+ }
2910
+
2911
+ async listAssetCategoryVersions(
2912
+ categoryId: string,
2913
+ options?: { page?: string; limit?: number; includeDeleted?: boolean },
2914
+ signal?: AbortSignal
2915
+ ): Promise<PaginatedResponse<AssetCategoryGet>> {
2916
+ const params = new URLSearchParams();
2917
+ if (options?.page !== undefined) params.set('page', options.page);
2918
+ if (options?.limit !== undefined) params.set('limit', options.limit.toString());
2919
+ if (options?.includeDeleted) params.set('includeDeleted', 'true');
2920
+
2921
+ const baseUrl = `${this._assetTypeCatalogUrl}/categories/${categoryId}/versions`;
2922
+ const url = params.toString() ? `${baseUrl}?${params}` : baseUrl;
2923
+ const response = await this._httpClient.fetch(HttpMethod.Get, url, {
2924
+ headers: { Accept: 'application/json' },
2925
+ signal
2926
+ });
2927
+
2928
+ if (response.ok) return (await response.json()) as PaginatedResponse<AssetCategoryGet>;
2929
+ if (response.status === 403) throw new AssetCategoryForbiddenError('Asset category version listing forbidden');
2930
+ if (response.status === 400) {
2931
+ const errorBody = await readErrorBody(response);
2932
+ throw new AssetCategoryValidationError(
2933
+ `Asset category version listing failed: ${errorBody.detail}`,
2934
+ errorBody
2935
+ );
2936
+ }
2937
+ if (response.status === 404) throw new AssetCategoryNotFoundError(`Asset category ${categoryId} not found`);
2938
+ throw new Error(`Failed to list asset category versions: ${response.status} ${response.statusText}`);
2939
+ }
2940
+
2941
+ // ---------- Asset types ----------
2942
+
2943
+ async listAssetTypes(
2944
+ categoryId: string,
2945
+ options?: { page?: string; limit?: number; includeDeleted?: boolean },
2946
+ signal?: AbortSignal
2947
+ ): Promise<PaginatedResponse<AssetTypeGet>> {
2948
+ const params = new URLSearchParams();
2949
+ if (options?.page !== undefined) params.set('page', options.page);
2950
+ if (options?.limit !== undefined) params.set('limit', options.limit.toString());
2951
+ if (options?.includeDeleted) params.set('includeDeleted', 'true');
2952
+
2953
+ const baseUrl = `${this._assetTypeCatalogUrl}/categories/${categoryId}/types`;
2954
+ const url = params.toString() ? `${baseUrl}?${params}` : baseUrl;
2955
+ const response = await this._httpClient.fetch(HttpMethod.Get, url, {
2956
+ headers: { Accept: 'application/json' },
2957
+ signal
2958
+ });
2959
+
2960
+ if (response.ok) return (await response.json()) as PaginatedResponse<AssetTypeGet>;
2961
+ if (response.status === 403) throw new AssetTypeForbiddenError('Asset type listing forbidden');
2962
+ if (response.status === 400) {
2963
+ const errorBody = await readErrorBody(response);
2964
+ throw new AssetTypeValidationError(`Asset type listing failed: ${errorBody.detail}`, errorBody);
2965
+ }
2966
+ throw new Error(`Failed to list asset types: ${response.status} ${response.statusText}`);
2967
+ }
2968
+
2969
+ async getAssetType(categoryId: string, typeId: string, signal?: AbortSignal): Promise<AssetTypeGet> {
2970
+ const response = await this._httpClient.fetch(
2971
+ HttpMethod.Get,
2972
+ `${this._assetTypeCatalogUrl}/categories/${categoryId}/types/${typeId}`,
2973
+ { headers: { Accept: 'application/json' }, signal }
2974
+ );
2975
+ if (response.ok) return (await response.json()) as AssetTypeGet;
2976
+ if (response.status === 403) throw new AssetTypeForbiddenError(`Asset type ${typeId} read forbidden`);
2977
+ if (response.status === 404) throw new AssetTypeNotFoundError(`Asset type ${typeId} not found`);
2978
+ throw new Error(`Failed to get asset type: ${response.status} ${response.statusText}`);
2979
+ }
2980
+
2981
+ async listAssetTypeVersions(
2982
+ categoryId: string,
2983
+ typeId: string,
2984
+ options?: { page?: string; limit?: number; includeDeleted?: boolean },
2985
+ signal?: AbortSignal
2986
+ ): Promise<PaginatedResponse<AssetTypeGet>> {
2987
+ const params = new URLSearchParams();
2988
+ if (options?.page !== undefined) params.set('page', options.page);
2989
+ if (options?.limit !== undefined) params.set('limit', options.limit.toString());
2990
+ if (options?.includeDeleted) params.set('includeDeleted', 'true');
2991
+
2992
+ const baseUrl = `${this._assetTypeCatalogUrl}/categories/${categoryId}/types/${typeId}/versions`;
2993
+ const url = params.toString() ? `${baseUrl}?${params}` : baseUrl;
2994
+ const response = await this._httpClient.fetch(HttpMethod.Get, url, {
2995
+ headers: { Accept: 'application/json' },
2996
+ signal
2997
+ });
2998
+
2999
+ if (response.ok) return (await response.json()) as PaginatedResponse<AssetTypeGet>;
3000
+ if (response.status === 403) throw new AssetTypeForbiddenError('Asset type version listing forbidden');
3001
+ if (response.status === 400) {
3002
+ const errorBody = await readErrorBody(response);
3003
+ throw new AssetTypeValidationError(`Asset type version listing failed: ${errorBody.detail}`, errorBody);
3004
+ }
3005
+ if (response.status === 404) throw new AssetTypeNotFoundError(`Asset type ${typeId} not found`);
3006
+ throw new Error(`Failed to list asset type versions: ${response.status} ${response.statusText}`);
3007
+ }
3008
+
3009
+ async putAssetType(
3010
+ categoryId: string,
3011
+ typeId: string,
3012
+ body: AssetTypePut,
3013
+ signal?: AbortSignal
3014
+ ): Promise<AssetTypeGet> {
3015
+ const response = await this._httpClient.fetch(
3016
+ HttpMethod.Put,
3017
+ `${this._assetTypeCatalogUrl}/categories/${categoryId}/types/${typeId}`,
3018
+ {
3019
+ headers: { 'Content-Type': 'application/json' },
3020
+ body: JSON.stringify(body),
3021
+ signal
3022
+ }
3023
+ );
3024
+ if (response.ok) return (await response.json()) as AssetTypeGet;
3025
+ if (response.status === 403) throw new AssetTypeForbiddenError('Asset type upsert forbidden');
3026
+ if (response.status === 409) {
3027
+ const current = (await response.json()) as AssetTypeGet;
3028
+ throw new AssetTypeConflictError('Asset type was modified by another user', current);
3029
+ }
3030
+ if (response.status === 400) {
3031
+ const errorBody = await readErrorBody(response);
3032
+ throw new AssetTypeValidationError(`Asset type validation failed: ${errorBody.detail}`, errorBody);
3033
+ }
3034
+ const errorText = await response.text();
3035
+ throw new Error(`Failed to upsert asset type: ${response.status} ${response.statusText} - ${errorText}`);
3036
+ }
3037
+
3038
+ async deleteAssetType(
3039
+ categoryId: string,
3040
+ typeId: string,
3041
+ etag: string,
3042
+ signal?: AbortSignal
3043
+ ): Promise<AssetTypeGet> {
3044
+ return this._typeDeleteOrRestore(categoryId, typeId, etag, /*isRestore*/ false, signal);
3045
+ }
3046
+
3047
+ async restoreAssetType(
3048
+ categoryId: string,
3049
+ typeId: string,
3050
+ etag: string,
3051
+ signal?: AbortSignal
3052
+ ): Promise<AssetTypeGet> {
3053
+ return this._typeDeleteOrRestore(categoryId, typeId, etag, /*isRestore*/ true, signal);
3054
+ }
3055
+
3056
+ private async _typeDeleteOrRestore(
3057
+ categoryId: string,
3058
+ typeId: string,
3059
+ etag: string,
3060
+ isRestore: boolean,
3061
+ signal?: AbortSignal
3062
+ ): Promise<AssetTypeGet> {
3063
+ const url = isRestore
3064
+ ? `${this._assetTypeCatalogUrl}/categories/${categoryId}/types/${typeId}/restore`
3065
+ : `${this._assetTypeCatalogUrl}/categories/${categoryId}/types/${typeId}`;
3066
+ const method = isRestore ? HttpMethod.Put : HttpMethod.Delete;
3067
+ const response = await this._httpClient.fetch(method, url, {
3068
+ headers: { 'Content-Type': 'application/json' },
3069
+ body: JSON.stringify({ etag }),
3070
+ signal
3071
+ });
3072
+ if (response.ok) return (await response.json()) as AssetTypeGet;
3073
+ if (response.status === 403) {
3074
+ const op = isRestore ? 'restore' : 'delete';
3075
+ throw new AssetTypeForbiddenError(`Asset type ${op} forbidden`);
3076
+ }
3077
+ if (response.status === 409) {
3078
+ const current = (await response.json()) as AssetTypeGet;
3079
+ throw new AssetTypeConflictError('Asset type was modified by another user', current);
3080
+ }
3081
+ if (response.status === 404) throw new AssetTypeNotFoundError(`Asset type ${typeId} not found`);
3082
+ const errorText = await response.text();
3083
+ const op = isRestore ? 'restore' : 'delete';
3084
+ throw new Error(`Failed to ${op} asset type: ${response.status} ${response.statusText} - ${errorText}`);
3085
+ }
3086
+
3087
+ async getAssetTypeCatalog(signal?: AbortSignal): Promise<AssetCatalogResponse> {
3088
+ const response = await this._httpClient.fetch(HttpMethod.Get, this._assetTypeCatalogUrl, {
3089
+ headers: { Accept: 'application/json' },
3090
+ signal
3091
+ });
3092
+ if (response.ok) return (await response.json()) as AssetCatalogResponse;
3093
+ if (response.status === 403) throw new AssetCategoryForbiddenError('Asset type catalog read forbidden');
3094
+ if (response.status === 400) {
3095
+ // The server emits a single structured 400 here today —
3096
+ // `cause: 'CatalogTooLarge'` — to flag a tenant whose catalog
3097
+ // overflows the per-customer cap. Surface as a typed error so
3098
+ // picker UIs can render "use search" instead of crashing on
3099
+ // missing rows. Any other 400 (malformed CatalogTooLarge body,
3100
+ // future cause) falls through to the generic error below so the
3101
+ // caller still sees the raw response.
3102
+ const errorBody = await readErrorBody(response);
3103
+ if (isAssetCatalogTooLargeErrorBody(errorBody)) {
3104
+ throw new AssetCatalogTooLargeError(`Asset type catalog too large: ${errorBody.detail}`, errorBody);
3105
+ }
3106
+ throw new Error(
3107
+ `Failed to get asset-type catalog: ${response.status} ${response.statusText} - ${errorBody.detail}`
3108
+ );
3109
+ }
3110
+ const errorText = await response.text();
3111
+ throw new Error(`Failed to get asset-type catalog: ${response.status} ${response.statusText} - ${errorText}`);
3112
+ }
3113
+ }