@twinfinity/twin
v6.2.0
Published
Twinfinity Twin Service client
Readme
@twinfinity/twin
TypeScript client for the Twinfinity Twin Service API. Provides typed access to twins, assets, and twin files with optimistic concurrency control and structured error handling.
Installation
bun add @twinfinity/twinPeer Dependencies
@twinfinity/authentication- providesTwinfinityHttpClientfor authenticated HTTP requests@twinfinity/t8-client-model- provides T8 client model types used byloadTwin/loadGeometryInfo@twinfinity/entity-reference- provides theEntityReferencetype returned byreferenceOfand the entity reference builders, andformatto turn one into a string
Usage
Creating a Client
import { TwinClient } from '@twinfinity/twin';
const client = new TwinClient(baseUrl, httpClient);The baseUrl is the API root (e.g. https://api.twinfinity.com). The client appends /_ps/twins internally.
Twin Operations
// List twins (paginated)
const page = await client.listTwins();
// List all twins (auto-paginates)
const allTwins = await client.listAllTwins();
// Get a specific twin
const twin = await client.getTwin('twin-id');
// Get a specific version
const twinV2 = await client.getTwin({ id: 'twin-id', version: 2 });
// Create or update
const updated = await client.createOrUpdateTwin('twin-id', {
relatedContainerIds: ['container-1'],
rootEntity: '0Site00000000000000000',
entities: {
sites: {
'0Site00000000000000000': { id: '0Site00000000000000000', name: 'My site' }
}
},
etag: twin.etag // required for updates
});
// Soft-delete and restore
const deleted = await client.deleteTwin('twin-id', twin.etag);
const restored = await client.restoreTwin('twin-id', deleted.etag);Nesting twins
A twin can nest other twins as children. Declare each child in entities.twinReferences
and place it under a site via that site's aggregatedTwinReferences:
await client.createOrUpdateTwin('parent-id', {
relatedContainerIds: ['container-1'],
rootEntity: '0Site00000000000000000',
entities: {
sites: {
'0Site00000000000000000': {
id: '0Site00000000000000000',
name: 'Campus site',
aggregatedTwinReferences: ['ref-a']
}
},
twinReferences: {
'ref-a': { twin: 'child-id' } // id of the nested twin
}
}
// Omit `etag` when creating; supply the current twin's etag only when updating.
});
// The parent/child edge is computed and read back on the twin:
const parent = await client.getTwin('parent-id');
parent.aggregatedTwins; // ['child-id'] — twins this twin parents
const child = await client.getTwin('child-id');
child.parentTwin; // 'parent-id' — the twin that references this oneaggregatedTwins and parentTwin are computed by the service, not authored — sending
them in a write has no effect. A twin may have at most one parent, may not reference
itself, and references may not form a cycle. Adding a reference requires View on the
referenced twin (in addition to Edit on the twin being written); a referenced twin you
cannot read is reported as not found. See
Twin Management → Nested Twins
for the full rules.
Loading Twin Models
The client can download and deserialize processed twin content (T8CM/T8GI formats):
// Load client model with default types
const { id, version, model } = await client.loadTwin('twin-id');
// Load with custom factory functions for domain-specific types
const custom = await client.loadTwinWithFactories(
'twin-id',
myProjectFactory,
myProductFactory,
myEntityTypeFactory,
myUnitFactory,
myConnectProject,
myConnectProduct
);
// Load geometry info
const geometry = await client.loadGeometryInfo('twin-id');
// Load client model with geometry combined
const withGeometry = await client.loadTwinWithGeometry('twin-id');When no version is specified, the client searches versions (newest first) for one that has the requested content format.
Entity References
Preview. Entity references are released to try out and may change. In particular the
twin~twin:<id>.entity:<gid>spelling is expected to change once platform responses carry their ownselfreference.
An entity reference names a twin, or an entity in it, in the form other Twinfinity services accept. referenceOf gives the entity reference of an object in a loaded twin: the twin itself for the model's project, an entity of the twin for one of its products. It makes no request.
import { format } from '@twinfinity/entity-reference';
const twin = await client.loadTwin('550e8400-e29b-41d4-a716-446655440000');
for (const product of twin.model?.products ?? []) {
const reference = client.referenceOf(product);
if (reference !== undefined) {
format(reference); // 'twin~twin:550e8400-e29b-41d4-a716-446655440000.entity:3WoDmit2L9H8xguu5dNQPk'
}
}The client records which twin each object was loaded for, so several twins can be loaded at once, through one client or several, and any client answers for any loaded object. entityReferenceOf(object) is the same function without a client.
The result is undefined, never an exception, when:
- the object was not loaded as the project or a product of a twin by a
loadTwin…call. That includes an entity type, a model read withoutTwinClient, and a product added tomodel.productsafter the load; - the product's
globalIdis empty or not a canonical IFC GlobalId, which occurs for DWG-derived objects and for twins stored before the Twin service required specification entity ids to be IFC GlobalIds; - the twin id is not a lowercase uuid. The id is used as you passed it to
loadTwin, so load with the lowercase spelling.
When you hold ids instead of a loaded object, use the builders. Each returns undefined under the same id rules, and none takes a version:
| Builder | Entity reference |
| ------------------------------------------------------ | ------------------------------------------------- |
| entityReferenceOfTwin(twinId) | twin~twin:<twinId> |
| entityReferenceOfTwinEntity(twinId, gid) | twin~twin:<twinId>.entity:<gid> |
| entityReferenceOfTwinFile(twinId, fileId) | twin~twin:<twinId>.file:<fileId> |
| entityReferenceOfTwinFileEntity(twinId, fileId, gid) | twin~twin:<twinId>.file:<fileId>/entities/<gid> |
A gid is unique only within a twin's merged model, so the merged-model and per-file spellings are separate functions and you choose between them. The per-file spelling is the one a twin specification's representedBy takes.
format and the EntityReference type come from @twinfinity/entity-reference, a peer dependency that must be installed alongside this package. See Entity References for the rules.
Asset Operations
// List assets in a twin
const assets = await client.listAssets('twin-id');
// Include soft-deleted assets
const allAssets = await client.listAssets('twin-id', 0, 100, { includeDeleted: true });
// Create assets
const created = await client.createAssets('twin-id', [{ name: 'Pump A', category: 'HVAC', type: 'Pump' }]);
// Update with optimistic concurrency
const updated = await client.updateAsset('twin-id', 'asset-id', {
etag: asset.etag,
name: 'Pump A (Updated)'
});
// Batch operations (atomic - all succeed or all roll back)
const result = await client.batchAssets('twin-id', [
{ operation: 'upsert', id: 'asset-1', item: { name: 'New Asset' } },
{ operation: 'delete', id: 'asset-2', item: { etag: 'etag-2' } },
{ operation: 'restore', id: 'asset-3', item: { etag: 'etag-3' } }
]);
// Batch get multiple assets by ID
const fetched = await client.batchGetAssets('twin-id', ['id-1', 'id-2']);Twin File Operations
// List files (with filtering)
const files = await client.listTwinFiles('twin-id', { checkedIn: true });
// Create or update a file
const file = await client.createOrUpdateTwinFile('twin-id', 'file-id', {
source: { changeId: 'doc-id', changeVersion: 1, sourceType: 'DocumentChange' },
name: 'model.ifc',
quality: 100
});
// Download file content
const blob = await client.getTwinFileContent('twin-id', 'file-id');Error Handling
All methods throw typed errors for specific HTTP failure codes. Unexpected status codes throw a generic Error.
Error Classes
| Error | HTTP Status | Context Attached |
| ------------------------- | ----------- | ------------------------------ |
| TwinValidationError | 400 | errorBody?: TwinErrorBody |
| TwinNotFoundError | 404 | errorBody?: TwinErrorBody |
| TwinConflictError | 409 | currentTwin: TwinGet |
| AssetValidationError | 400 | errorBody?: TwinErrorBody |
| AssetForbiddenError | 403 | - |
| AssetNotFoundError | 404 | - |
| AssetConflictError | 409 | currentAsset: AssetGet |
| AssetBatchError | 404, 409 | response: AssetBatchResponse |
| TwinFileValidationError | 400 | errorBody?: TwinErrorBody |
| TwinFileNotFoundError | 404 | - |
| TwinFileConflictError | 409 | currentTwinFile: TwinFileGet |
| ProcessorNotFoundError | 404 | - |
Twin-scoped permissions (/twins/{twinId}/permissions, /twins/{twinId}/groups):
| Error | HTTP Status | Context Attached |
| --------------------------------------- | ----------- | --------------------------------------------------------- |
| TwinPermissionsForbiddenError | 403 | errorBody?: TwinErrorBody |
| TwinPrincipalNotFoundError | 404 | errorBody?: TwinErrorBody |
| TwinPermissionsConflictError | 409 | conflictCause, currentState?, errorBody |
| TwinTemporarilyUnavailableError | 503 | errorBody?: TwinErrorBody, retryAfterSeconds?: number |
TwinPermissionsConflictError names the conflict on conflictCause rather than on cause, leaving
the standard Error.cause free: EtagMismatch is retried by re-reading, EtagRequired by reading
at all, and ScopesNotProvisioned is not retryable. currentState carries the entry as the server
holds it, including the etag to retry with.
Asset-domain permissions (/twins/permissions, /twins/groups) reuse the errors above, except that
a 409 arrives as AssetPermissionsConflictError — the same shape, but its currentState is an
AssetPrincipalPermissionsGet rather than a twin's entry, and ScopesNotProvisioned never occurs
because the domain's scope is seeded once per customer rather than provisioned per resource.
Conflict Handling
Conflict errors (409) carry the current server-side state, enabling retry logic:
import { TwinConflictError } from '@twinfinity/twin';
try {
await client.createOrUpdateTwin('twin-id', twinData);
} catch (e) {
if (e instanceof TwinConflictError) {
// e.currentTwin contains the server's current version
// Use it to merge changes and retry
const merged = mergeChanges(twinData, e.currentTwin);
await client.createOrUpdateTwin('twin-id', {
...merged,
etag: e.currentTwin.etag
});
}
}Validation Error Body
TwinValidationError, TwinFileValidationError, and AssetValidationError may include a structured error body:
interface TwinErrorBody {
detail: string;
cause: string;
item?: {
kind: string;
id: string;
version?: number;
field?: string;
};
}Batch Error Handling
AssetBatchError carries the full batch response with per-item results, including which operations were reverted:
import { AssetBatchError } from '@twinfinity/twin';
try {
await client.batchAssets('twin-id', operations);
} catch (e) {
if (e instanceof AssetBatchError) {
for (const result of e.response.results) {
if (result.status === 'conflicted') {
console.log(`Item ${result.index} conflicted: ${result.error?.detail}`);
}
}
}
}Exports
The package has two export paths:
@twinfinity/twin- Public API:TwinClient,ITwinClient, all types, all error classes@twinfinity/twin/internal/*- Internal modules (no stability guarantees)
