From 72780ec122a284e9d4662bafec0abfbeccbfd46b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:31:03 +0000 Subject: [PATCH 1/2] fix: Make action attempt error and result nullable The generated ActionAttempt type collapsed the per-status API variants and claimed non-null error and result on every variant, so reading result on a pending attempt or error on a successful one typechecked and crashed at runtime. The API sends null for result unless the status is success and null for error unless the status is error. The blueprint merges the per-status variants and keeps the non-nullable property definitions, so normalize the action attempt properties during generation: error and result are nullable with the null semantics documented. SucceededActionAttempt and FailedActionAttempt now assert the resolved shape, so narrowing through resolveActionAttempt or a status check keeps result and error reads compiling. Guard SeamActionAttemptFailedError against a failed attempt without an error object instead of crashing with a null dereference. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 --- codegen/lib/layouts/resources.ts | 32 ++- src/lib/resolve-action-attempt.ts | 9 +- src/lib/resources/action-attempt.ts | 193 +++++++++--------- test/seam/connect/resource-types.test.ts | 31 +++ .../connect/wait-for-action-attempt.test.ts | 33 +++ 5 files changed, 193 insertions(+), 105 deletions(-) diff --git a/codegen/lib/layouts/resources.ts b/codegen/lib/layouts/resources.ts index 718edb96..a9bcfeb7 100644 --- a/codegen/lib/layouts/resources.ts +++ b/codegen/lib/layouts/resources.ts @@ -1,4 +1,9 @@ -import type { Blueprint, Resource } from '@seamapi/blueprint' +import type { + ActionAttempt, + Blueprint, + Property, + Resource, +} from '@seamapi/blueprint' import { kebabCase, pascalCase } from 'change-case' export interface ResourceLayoutContext { @@ -39,7 +44,7 @@ export const getResourceLayoutContexts = ( ({ resourceType }) => !discriminatedResourceTypes.has(resourceType), ), ...blueprint.events, - ...blueprint.actionAttempts, + ...blueprint.actionAttempts.map(normalizeActionAttempt), ] const resourceTypes = [ ...new Set(resources.map(({ resourceType }) => resourceType)), @@ -57,6 +62,29 @@ export const getResourceLayoutContexts = ( })) } +// The blueprint merges the per-status action attempt variants and keeps the +// non-nullable definitions of error and result, but the API sends null for +// both unless the status is error or success respectively. +const nullWhilePendingDoc = + 'Null while the action attempt is pending or when this value does not apply.' + +const normalizeActionAttempt = (resource: ActionAttempt): ActionAttempt => ({ + ...resource, + properties: resource.properties.map((property) => + property.name === 'error' || property.name === 'result' + ? toStatusDependentProperty(property) + : property, + ), +}) + +const toStatusDependentProperty = (property: Property): Property => ({ + ...property, + isNullable: true, + description: [property.description, nullWhilePendingDoc] + .filter((part) => part !== '') + .join(' '), +}) + const getBatchResourceLayoutContexts = ( resources: Resource[], ): BatchResourceLayoutContext[] => { diff --git a/src/lib/resolve-action-attempt.ts b/src/lib/resolve-action-attempt.ts index e01607be..5dad2333 100644 --- a/src/lib/resolve-action-attempt.ts +++ b/src/lib/resolve-action-attempt.ts @@ -123,9 +123,10 @@ export class SeamActionAttemptFailedError< code: string constructor(actionAttempt: FailedActionAttempt) { - super(actionAttempt.error.message, actionAttempt) + const { error } = actionAttempt as T + super(error?.message ?? 'Action attempt failed', actionAttempt) this.name = this.constructor.name - this.code = actionAttempt.error.type + this.code = error?.type ?? 'unknown' } } @@ -167,6 +168,8 @@ const isFailedActionAttempt = ( */ export type SucceededActionAttempt = T & { status: 'success' + result: NonNullable + error: null } /** @@ -174,4 +177,6 @@ export type SucceededActionAttempt = T & { */ export type FailedActionAttempt = T & { status: 'error' + error: NonNullable + result: null } diff --git a/src/lib/resources/action-attempt.ts b/src/lib/resources/action-attempt.ts index 2b6d7cd5..cdcc58a4 100644 --- a/src/lib/resources/action-attempt.ts +++ b/src/lib/resources/action-attempt.ts @@ -19,7 +19,7 @@ export type ActionAttempt = action_type: 'LOCK_DOOR' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -31,17 +31,16 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ result: { /** * Indicates whether the device confirmed that the lock action occurred. */ was_confirmed_by_device?: boolean | undefined - } + } | null status: 'success' | 'pending' | 'error' } @@ -57,7 +56,7 @@ export type ActionAttempt = action_type: 'UNLOCK_DOOR' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -69,17 +68,16 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ result: { /** * Indicates whether the device confirmed that the unlock action occurred. */ was_confirmed_by_device?: boolean | undefined - } + } | null status: 'success' | 'pending' | 'error' } @@ -94,6 +92,9 @@ export type ActionAttempt = */ action_type: 'SCAN_CREDENTIAL' + /** + * Null while the action attempt is pending or when this value does not apply. + */ error: { /** * Detailed description of the error. Provides insights into the issue and potentially how to rectify it. @@ -110,10 +111,9 @@ export type ActionAttempt = | 'encoder_not_online' | 'encoder_communication_timeout' | 'bridge_disconnected' - } - + } | null /** - * Result of scanning a card. If the attempt was successful, includes a snapshot of credential data read from the physical encoder, the corresponding data stored on Seam and the access system, and any associated warnings. + * Result of scanning a card. If the attempt was successful, includes a snapshot of credential data read from the physical encoder, the corresponding data stored on Seam and the access system, and any associated warnings. Null while the action attempt is pending or when this value does not apply. */ result: { /** @@ -466,7 +466,7 @@ export type ActionAttempt = */ warning_message: string }> - } + } | null status: 'success' | 'pending' | 'error' } @@ -481,6 +481,9 @@ export type ActionAttempt = */ action_type: 'ENCODE_CREDENTIAL' + /** + * Null while the action attempt is pending or when this value does not apply. + */ error: { /** * Detailed description of the error. Provides insights into the issue and potentially how to rectify it. @@ -501,10 +504,9 @@ export type ActionAttempt = | 'bridge_disconnected' | 'encoding_interrupted' | 'credential_deleted' - } - + } | null /** - * Result of an encoding attempt. If the attempt was successful, includes the credential data that was encoded onto the card. + * Result of an encoding attempt. If the attempt was successful, includes the credential data that was encoded onto the card. Null while the action attempt is pending or when this value does not apply. */ result: { /** @@ -755,7 +757,7 @@ export type ActionAttempt = * ID of the workspace that contains the [credential](https://docs.seam.co/low-level-apis/access-systems/managing-credentials). */ workspace_id: string - } + } | null status: 'success' | 'pending' | 'error' } @@ -770,6 +772,9 @@ export type ActionAttempt = */ action_type: 'SCAN_TO_ASSIGN_CREDENTIAL' + /** + * Null while the action attempt is pending or when this value does not apply. + */ error: { /** * Detailed description of the error. Provides insights into the issue and potentially how to rectify it. @@ -783,10 +788,9 @@ export type ActionAttempt = | 'uncategorized_error' | 'action_attempt_expired' | 'no_credential_on_encoder' - } - + } | null /** - * Result of a scan to assign attempt. If the attempt was successful, includes the credential data that was scanned and assigned. + * Result of a scan to assign attempt. If the attempt was successful, includes the credential data that was scanned and assigned. Null while the action attempt is pending or when this value does not apply. */ result: { /** @@ -1039,7 +1043,7 @@ export type ActionAttempt = * ID of the workspace that contains the [credential](https://docs.seam.co/low-level-apis/access-systems/managing-credentials). */ workspace_id: string - } + } | null status: 'success' | 'pending' | 'error' } @@ -1054,6 +1058,9 @@ export type ActionAttempt = */ action_type: 'ASSIGN_CREDENTIAL' + /** + * Null while the action attempt is pending or when this value does not apply. + */ error: { /** * Detailed description of the error. Provides insights into the issue and potentially how to rectify it. @@ -1067,10 +1074,9 @@ export type ActionAttempt = | 'uncategorized_error' | 'action_attempt_expired' | 'credential_not_found' - } - + } | null /** - * Result of assigning a credential. If successful, includes the updated access method with the assigned credential. + * Result of assigning a credential. If successful, includes the updated access method with the assigned credential. Null while the action attempt is pending or when this value does not apply. */ result: { /** @@ -1241,7 +1247,7 @@ export type ActionAttempt = * ID of the Seam workspace associated with the access method. */ workspace_id: string - } + } | null status: 'success' | 'pending' | 'error' } @@ -1257,7 +1263,7 @@ export type ActionAttempt = action_type: 'RESET_SANDBOX_WORKSPACE' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1269,12 +1275,11 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ - result: {} + result: {} | null status: 'success' | 'pending' | 'error' } @@ -1290,7 +1295,7 @@ export type ActionAttempt = action_type: 'SET_FAN_MODE' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1302,12 +1307,11 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ - result: {} + result: {} | null status: 'success' | 'pending' | 'error' } @@ -1323,7 +1327,7 @@ export type ActionAttempt = action_type: 'SET_HVAC_MODE' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1335,12 +1339,11 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ - result: {} + result: {} | null status: 'success' | 'pending' | 'error' } @@ -1356,7 +1359,7 @@ export type ActionAttempt = action_type: 'ACTIVATE_CLIMATE_PRESET' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1368,12 +1371,11 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ - result: {} + result: {} | null status: 'success' | 'pending' | 'error' } @@ -1389,7 +1391,7 @@ export type ActionAttempt = action_type: 'SIMULATE_KEYPAD_CODE_ENTRY' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1401,12 +1403,11 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ - result: {} + result: {} | null status: 'success' | 'pending' | 'error' } @@ -1422,7 +1423,7 @@ export type ActionAttempt = action_type: 'SIMULATE_MANUAL_LOCK_VIA_KEYPAD' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1434,12 +1435,11 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ - result: {} + result: {} | null status: 'success' | 'pending' | 'error' } @@ -1455,7 +1455,7 @@ export type ActionAttempt = action_type: 'PUSH_THERMOSTAT_PROGRAMS' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1467,12 +1467,11 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ - result: {} + result: {} | null status: 'success' | 'pending' | 'error' } @@ -1488,7 +1487,7 @@ export type ActionAttempt = action_type: 'CONFIGURE_AUTO_LOCK' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1500,12 +1499,11 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ - result: {} + result: {} | null status: 'success' | 'pending' | 'error' } @@ -1521,7 +1519,7 @@ export type ActionAttempt = action_type: 'SYNC_ACCESS_CODES' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1533,12 +1531,11 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ - result: {} + result: {} | null status: 'success' | 'pending' | 'error' } @@ -1554,7 +1551,7 @@ export type ActionAttempt = action_type: 'CREATE_ACCESS_CODE' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1566,17 +1563,16 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ result: { /** * Created access code. */ access_code: Record - } + } | null status: 'success' | 'pending' | 'error' } @@ -1592,7 +1588,7 @@ export type ActionAttempt = action_type: 'DELETE_ACCESS_CODE' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1604,12 +1600,11 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ - result: {} + result: {} | null status: 'success' | 'pending' | 'error' } @@ -1625,7 +1620,7 @@ export type ActionAttempt = action_type: 'UPDATE_ACCESS_CODE' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1637,17 +1632,16 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ result: { /** * Updated access code. */ access_code?: Record | undefined - } + } | null status: 'success' | 'pending' | 'error' } @@ -1663,7 +1657,7 @@ export type ActionAttempt = action_type: 'CREATE_NOISE_THRESHOLD' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1675,17 +1669,16 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ result: { /** * Created noise threshold. */ noise_threshold: Record - } + } | null status: 'success' | 'pending' | 'error' } @@ -1701,7 +1694,7 @@ export type ActionAttempt = action_type: 'DELETE_NOISE_THRESHOLD' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1713,12 +1706,11 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ - result: {} + result: {} | null status: 'success' | 'pending' | 'error' } @@ -1734,7 +1726,7 @@ export type ActionAttempt = action_type: 'UPDATE_NOISE_THRESHOLD' /** - * Error associated with the action. + * Error associated with the action. Null while the action attempt is pending or when this value does not apply. */ error: { /** @@ -1746,17 +1738,16 @@ export type ActionAttempt = * Type of the error. */ type: string - } - + } | null /** - * Result of the action. + * Result of the action. Null while the action attempt is pending or when this value does not apply. */ result: { /** * Updated noise threshold. */ noise_threshold: Record - } + } | null status: 'success' | 'pending' | 'error' } diff --git a/test/seam/connect/resource-types.test.ts b/test/seam/connect/resource-types.test.ts index 5a8a1f1f..4bec08f2 100644 --- a/test/seam/connect/resource-types.test.ts +++ b/test/seam/connect/resource-types.test.ts @@ -2,7 +2,10 @@ import test from 'ava' import type { AccessCode, + ActionAttempt, Device, + FailedActionAttempt, + SucceededActionAttempt, UnmanagedAccessCode, } from '@seamapi/http/connect' @@ -29,6 +32,34 @@ test('access code resources narrow on is_managed', (t) => { t.is(typeof assertAccessCodeNarrowing, 'function') }) +const assertActionAttemptNullability = (actionAttempt: ActionAttempt): void => { + expectType(actionAttempt.error) + expectType(actionAttempt.result) + + // @ts-expect-error The result is null unless the action attempt succeeded. + Object.keys(actionAttempt.result) + + // @ts-expect-error The error is null unless the action attempt failed. + expectType(actionAttempt.error.message) +} + +const assertResolvedActionAttemptNarrowing = ( + succeeded: SucceededActionAttempt, + failed: FailedActionAttempt, +): void => { + expectType(succeeded.result) + expectType(succeeded.error) + + expectType(failed.error.message) + expectType(failed.error.type) + expectType(failed.result) +} + +test('action attempt error and result are null unless resolved', (t) => { + t.is(typeof assertActionAttemptNullability, 'function') + t.is(typeof assertResolvedActionAttemptNarrowing, 'function') +}) + const assertCustomMetadataValueTypes = (device: Device): void => { expectType>(device.custom_metadata) diff --git a/test/seam/connect/wait-for-action-attempt.test.ts b/test/seam/connect/wait-for-action-attempt.test.ts index 8995b9da..5b1a5fba 100644 --- a/test/seam/connect/wait-for-action-attempt.test.ts +++ b/test/seam/connect/wait-for-action-attempt.test.ts @@ -1,5 +1,6 @@ import test from 'ava' import { getTestServer } from 'fixtures/seam/connect/api.js' +import nock from 'nock' import { SeamActionAttemptFailedError, @@ -204,6 +205,38 @@ test('waitForActionAttempt: times out if waiting for polling interval', async (t t.deepEqual(err?.actionAttempt, actionAttempt) }) +test('waitForActionAttempt: rejects when a failed action attempt has no error object', async (t) => { + const { seed, endpoint } = await getTestServer(t) + + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { endpoint }) + + nock(endpoint) + .post('/locks/unlock_door') + .reply(200, { + action_attempt: { + action_attempt_id: 'e2192660-0e45-4a11-9800-eb4d086cca09', + action_type: 'UNLOCK_DOOR', + status: 'error', + error: null, + result: null, + }, + }) + + const err = await t.throwsAsync( + async () => + await seam.locks.unlockDoor( + { device_id: seed.august_device_1 }, + { waitForActionAttempt: true }, + ), + { + instanceOf: SeamActionAttemptFailedError, + message: 'Action attempt failed', + }, + ) + + t.is(err?.code, 'unknown') +}) + test('waitForActionAttempt: waits directly on returned action attempt', async (t) => { const { seed, endpoint } = await getTestServer(t) From 3c055de089f659b4c0503589e607346904330e52 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 23:29:03 +0000 Subject: [PATCH 2/2] docs: Trim the action attempt normalizer comment Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 --- codegen/lib/layouts/resources.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/codegen/lib/layouts/resources.ts b/codegen/lib/layouts/resources.ts index a9bcfeb7..4d05e5c7 100644 --- a/codegen/lib/layouts/resources.ts +++ b/codegen/lib/layouts/resources.ts @@ -62,9 +62,7 @@ export const getResourceLayoutContexts = ( })) } -// The blueprint merges the per-status action attempt variants and keeps the -// non-nullable definitions of error and result, but the API sends null for -// both unless the status is error or success respectively. +// The blueprint merges the per-status variants and drops the nullability. const nullWhilePendingDoc = 'Null while the action attempt is pending or when this value does not apply.'