import { ConditionalCheckFailedException, ConsumedCapacity, ItemCollectionMetrics, } from '@aws-sdk/client-dynamodb'; import type { DeleteCommandInput, GetCommandInput, UpdateCommandInput, } from '@aws-sdk/lib-dynamodb'; import { DeleteCommand, GetCommand, QueryCommand, QueryCommandInput, UpdateCommand, } from '@aws-sdk/lib-dynamodb'; import {ServiceException} from '@aws-sdk/smithy-client'; import type {NativeAttributeValue} from '@aws-sdk/util-dynamodb'; import type {ResultType} from '@ianwremmel/data'; import { assert, makeSortKeyForQuery, unmarshallRequiredField, unmarshallOptionalField, AlreadyExistsError, AssertionError, BaseDataLibraryError, DataIntegrityError, MultiResultType, NotFoundError, OptimisticLockingError, QueryOptions, UnexpectedAwsError, UnexpectedError, } from '@ianwremmel/data'; import Base64 from 'base64url'; import {ddbDocClient} from '../../dependencies'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Exact = {[K in keyof T]: T[K]}; export type MakeOptional = Omit & { [SubKey in K]?: Maybe; }; export type MakeMaybe = Omit & { [SubKey in K]: Maybe; }; /** All built-in and custom scalars, mapped to their actual values */ export interface Scalars { ID: string; String: string; Boolean: boolean; Int: number; Float: number; /** JavaScript Date stored as a Number in DynamoDB */ Date: Date; /** Arbitrary JSON stored as a Map in DynamoDB */ JSONObject: Record; } /** CDC Event Types */ export type CdcEvent = 'INSERT' | 'MODIFY' | 'REMOVE' | 'UPSERT'; /** Possible case types for converting a fieldName to a DynamoDB column_name. */ export type ColumnCase = 'CAMEL_CASE' | 'SNAKE_CASE'; /** Configuration specific to a table dispatcher */ export interface DispatcherConfig { lambdaConfig?: InputMaybe; } /** Configuration specific to a model handler */ export interface HandlerConfig { lambdaConfig?: InputMaybe; } /** Reusable options for all generated lambdas */ export interface LambdaConfig { /** Measured in megabytes. */ memory?: InputMaybe; /** * Measured in seconds. Reminder that handlers may need to do retries in-band, so * consider making this a relatively high number and using alarms to catch * timeouts rather than terminating the function. In order to make space for up * to 5 retries, please add sixty seconds to your intended timeout. */ timeout?: InputMaybe; } /** * Models are DynamoDB tables with a key schema that may or may not include a sort * key. A Model must be decorated with either @partitionKey or @compositeKey. * * Note that, while Model does not explicitly implement Node, its `id` field * behaves like `Node#id` typically does. This is to avoid defining Node in the * injected schema if the consumer's schema also defined Node or defines it * differently. */ export interface Model { createdAt: Scalars['Date']; id: Scalars['ID']; updatedAt: Scalars['Date']; version: Scalars['Int']; } /** The Node interface */ export interface Node { id: Scalars['ID']; } /** * INCLUDE is omitted at this time because it drastically complicates the schema * DSL. If a use for it arises, it'll be revisited. */ export type ProjectionType = 'ALL' | 'KEYS_ONLY'; /** * Like Model, but includes a `publicId` field which, unlike `id`, is semantically * meaningless. Types implementing PublicModel will have an additional function, * `queryByPublicId`, generated. If any of your models implement PublicModel, then * the dependencies module must include an `idGenerator()`. */ export interface PublicModel { createdAt: Scalars['Date']; id: Scalars['ID']; publicId: Scalars['String']; updatedAt: Scalars['Date']; version: Scalars['Int']; } /** The Query type */ export interface Query { __typename?: 'Query'; node?: Maybe; } /** The Query type */ export interface QueryNodeArgs { id: Scalars['ID']; } /** * Automatically adds a createdAt and updatedAt timestamp to the entity and sets * them appropriately. The createdAt timestamp is only set on create, while the * updatedAt timestamp is set on create and update. */ export interface Timestamped { /** Set automatically when the item is first written */ createdAt: Scalars['Date']; /** Set automatically when the item is updated */ updatedAt: Scalars['Date']; } /** A user session object. */ export type UserSession = Model & Node & Timestamped & Versioned & { __typename?: 'UserSession'; createdAt: Scalars['Date']; expires: Scalars['Date']; id: Scalars['ID']; session: Scalars['JSONObject']; /** * Since `id` is a reserved field, sessionId is the field we'll use to inject a * random uuid, which the underlying system will use as the basis for `id`. */ sessionId: Scalars['String']; updatedAt: Scalars['Date']; version: Scalars['Int']; }; /** * Automatically adds a column to enable optimistic locking. This field shouldn't * be manipulated directly, but may need to be passed around by the runtime in * order to make updates. */ export interface Versioned { version: Scalars['Int']; } export interface UserSessionPrimaryKey { sessionId: Scalars['String']; } export type CreateUserSessionInput = Omit< UserSession, 'createdAt' | 'expires' | 'id' | 'updatedAt' | 'version' > & Partial>; export type CreateUserSessionOutput = ResultType; /** */ export async function createUserSession( input: Readonly ): Promise> { const tableName = process.env.TABLE_USER_SESSION; assert(tableName, 'TABLE_USER_SESSION is not set'); const now = new Date(); const { ExpressionAttributeNames, ExpressionAttributeValues, UpdateExpression, } = marshallUserSession(input, now); try { // Reminder: we use UpdateCommand rather than PutCommand because PutCommand // cannot return the newly written values. const commandInput: UpdateCommandInput = { ConditionExpression: 'attribute_not_exists(#pk)', ExpressionAttributeNames: { ...ExpressionAttributeNames, '#createdAt': '_ct', }, ExpressionAttributeValues: { ...ExpressionAttributeValues, ':createdAt': now.getTime(), }, Key: {pk: ['USER_SESSION', input.sessionId].join('#')}, ReturnConsumedCapacity: 'INDEXES', ReturnItemCollectionMetrics: 'SIZE', ReturnValues: 'ALL_NEW', TableName: tableName, UpdateExpression: [ ...UpdateExpression.split(', '), '#createdAt = :createdAt', ].join(', '), }; const { ConsumedCapacity: capacity, ItemCollectionMetrics: metrics, Attributes: item, } = await ddbDocClient.send(new UpdateCommand(commandInput)); assert( capacity, 'Expected ConsumedCapacity to be returned. This is a bug in codegen.' ); assert(item, 'Expected DynamoDB to return an Attributes prop.'); assert( item._et === 'UserSession', () => new DataIntegrityError( `Expected to write UserSession but wrote ${item?._et} instead` ) ); return { capacity, item: unmarshallUserSession(item), metrics, }; } catch (err) { if (err instanceof ConditionalCheckFailedException) { throw new AlreadyExistsError('UserSession', { pk: ['USER_SESSION', input.sessionId].join('#'), }); } if (err instanceof AssertionError || err instanceof BaseDataLibraryError) { throw err; } if (err instanceof ServiceException) { throw new UnexpectedAwsError(err); } throw new UnexpectedError(err); } } export type BlindWriteUserSessionInput = Omit< UserSession, 'createdAt' | 'expires' | 'id' | 'updatedAt' | 'version' > & Partial> & Partial>; export type BlindWriteUserSessionOutput = ResultType; /** */ export async function blindWriteUserSession( input: Readonly ): Promise> { const tableName = process.env.TABLE_USER_SESSION; assert(tableName, 'TABLE_USER_SESSION is not set'); const now = new Date(); const { ExpressionAttributeNames, ExpressionAttributeValues, UpdateExpression, } = marshallUserSession(input, now); delete ExpressionAttributeNames['#pk']; delete ExpressionAttributeValues[':version']; const ean = { ...ExpressionAttributeNames, '#createdAt': '_ct', }; const eav = { ...ExpressionAttributeValues, ':one': 1, ':createdAt': now.getTime(), }; const ue = `${[ ...UpdateExpression.split(', ').filter((e) => !e.startsWith('#version')), '#createdAt = if_not_exists(#createdAt, :createdAt)', ].join(', ')} ADD #version :one`; const commandInput: UpdateCommandInput = { ExpressionAttributeNames: ean, ExpressionAttributeValues: eav, Key: {pk: ['USER_SESSION', input.sessionId].join('#')}, ReturnConsumedCapacity: 'INDEXES', ReturnItemCollectionMetrics: 'SIZE', ReturnValues: 'ALL_NEW', TableName: tableName, UpdateExpression: ue, }; try { const { ConsumedCapacity: capacity, ItemCollectionMetrics: metrics, Attributes: item, } = await ddbDocClient.send(new UpdateCommand(commandInput)); assert( capacity, 'Expected ConsumedCapacity to be returned. This is a bug in codegen.' ); assert(item, 'Expected DynamoDB ot return an Attributes prop.'); assert( item._et === 'UserSession', () => new DataIntegrityError( `Expected to write UserSession but wrote ${item?._et} instead` ) ); return { capacity, item: unmarshallUserSession(item), metrics, }; } catch (err) { if (err instanceof AssertionError || err instanceof BaseDataLibraryError) { throw err; } if (err instanceof ServiceException) { throw new UnexpectedAwsError(err); } throw new UnexpectedError(err); } } export type DeleteUserSessionOutput = ResultType; /** */ export async function deleteUserSession( input: UserSessionPrimaryKey ): Promise { const tableName = process.env.TABLE_USER_SESSION; assert(tableName, 'TABLE_USER_SESSION is not set'); try { const commandInput: DeleteCommandInput = { ConditionExpression: 'attribute_exists(#pk)', ExpressionAttributeNames: { '#pk': 'pk', }, Key: {pk: ['USER_SESSION', input.sessionId].join('#')}, ReturnConsumedCapacity: 'INDEXES', ReturnItemCollectionMetrics: 'SIZE', ReturnValues: 'NONE', TableName: tableName, }; const {ConsumedCapacity: capacity, ItemCollectionMetrics: metrics} = await ddbDocClient.send(new DeleteCommand(commandInput)); assert( capacity, 'Expected ConsumedCapacity to be returned. This is a bug in codegen.' ); return { capacity, item: undefined, metrics, }; } catch (err) { if (err instanceof ConditionalCheckFailedException) { throw new NotFoundError('UserSession', input); } if (err instanceof AssertionError || err instanceof BaseDataLibraryError) { throw err; } if (err instanceof ServiceException) { throw new UnexpectedAwsError(err); } throw new UnexpectedError(err); } } export type ReadUserSessionOutput = ResultType; /** */ export async function readUserSession( input: UserSessionPrimaryKey ): Promise> { const tableName = process.env.TABLE_USER_SESSION; assert(tableName, 'TABLE_USER_SESSION is not set'); const commandInput: GetCommandInput = { ConsistentRead: true, Key: {pk: ['USER_SESSION', input.sessionId].join('#')}, ReturnConsumedCapacity: 'INDEXES', TableName: tableName, }; try { const {ConsumedCapacity: capacity, Item: item} = await ddbDocClient.send( new GetCommand(commandInput) ); assert( capacity, 'Expected ConsumedCapacity to be returned. This is a bug in codegen.' ); assert(item, () => new NotFoundError('UserSession', input)); assert( item._et === 'UserSession', () => new DataIntegrityError( `Expected ${JSON.stringify(input)} to load a UserSession but loaded ${ item._et } instead` ) ); return { capacity, item: unmarshallUserSession(item), metrics: undefined, }; } catch (err) { if (err instanceof AssertionError || err instanceof BaseDataLibraryError) { throw err; } if (err instanceof ServiceException) { throw new UnexpectedAwsError(err); } throw new UnexpectedError(err); } } export type UpdateUserSessionInput = Omit< UserSession, 'createdAt' | 'expires' | 'id' | 'updatedAt' > & Partial>; export type UpdateUserSessionOutput = ResultType; /** */ export async function updateUserSession( input: Readonly ): Promise> { const tableName = process.env.TABLE_USER_SESSION; assert(tableName, 'TABLE_USER_SESSION is not set'); const { ExpressionAttributeNames, ExpressionAttributeValues, UpdateExpression, } = marshallUserSession(input); try { let previousVersionCE = ''; let previousVersionEAV = {}; if ('version' in input && typeof input.version !== 'undefined') { previousVersionCE = '#version = :previousVersion AND '; previousVersionEAV = {':previousVersion': input.version}; } const commandInput: UpdateCommandInput = { ConditionExpression: `${previousVersionCE}#entity = :entity AND attribute_exists(#pk)`, ExpressionAttributeNames, ExpressionAttributeValues: { ...ExpressionAttributeValues, ...previousVersionEAV, }, Key: {pk: ['USER_SESSION', input.sessionId].join('#')}, ReturnConsumedCapacity: 'INDEXES', ReturnItemCollectionMetrics: 'SIZE', ReturnValues: 'ALL_NEW', TableName: tableName, UpdateExpression, }; const { Attributes: item, ConsumedCapacity: capacity, ItemCollectionMetrics: metrics, } = await ddbDocClient.send(new UpdateCommand(commandInput)); assert( capacity, 'Expected ConsumedCapacity to be returned. This is a bug in codegen.' ); assert(item, 'Expected DynamoDB to return an Attributes prop.'); assert( item._et === 'UserSession', () => new DataIntegrityError( `Expected ${JSON.stringify({ sessionId: input.sessionId, })} to update a UserSession but updated ${item._et} instead` ) ); return { capacity, item: unmarshallUserSession(item), metrics, }; } catch (err) { if (err instanceof ConditionalCheckFailedException) { try { await readUserSession(input); } catch { throw new NotFoundError('UserSession', {sessionId: input.sessionId}); } throw new OptimisticLockingError('UserSession', { sessionId: input.sessionId, }); } if (err instanceof AssertionError || err instanceof BaseDataLibraryError) { throw err; } if (err instanceof ServiceException) { throw new UnexpectedAwsError(err); } throw new UnexpectedError(err); } } export interface MarshallUserSessionOutput { ExpressionAttributeNames: Record; ExpressionAttributeValues: Record; UpdateExpression: string; } export type MarshallUserSessionInput = Required< Pick > & Partial>; /** Marshalls a DynamoDB record into a UserSession object */ export function marshallUserSession( input: MarshallUserSessionInput, now = new Date() ): MarshallUserSessionOutput { const updateExpression: string[] = [ '#entity = :entity', '#session = :session', '#sessionId = :sessionId', '#updatedAt = :updatedAt', '#version = :version', ]; const ean: Record = { '#entity': '_et', '#pk': 'pk', '#session': 'session', '#sessionId': 'session_id', '#updatedAt': '_md', '#version': '_v', }; const eav: Record = { ':entity': 'UserSession', ':session': input.session, ':sessionId': input.sessionId, ':updatedAt': now.getTime(), ':version': ('version' in input ? input.version ?? 0 : 0) + 1, }; if ('expires' in input && typeof input.expires !== 'undefined') { assert( !Number.isNaN(input.expires.getTime()), 'expires was passed but is not a valid date' ); ean['#expires'] = 'ttl'; eav[':expires'] = input.expires === null ? null : Math.floor(input.expires.getTime() / 1000); updateExpression.push('#expires = :expires'); } else { ean['#expires'] = 'ttl'; eav[':expires'] = now.getTime() + 86400000; updateExpression.push('#expires = :expires'); } updateExpression.sort(); return { ExpressionAttributeNames: ean, ExpressionAttributeValues: eav, UpdateExpression: `SET ${updateExpression.join(', ')}`, }; } /** Unmarshalls a DynamoDB record into a UserSession object */ export function unmarshallUserSession(item: Record): UserSession { const result: UserSession = { createdAt: unmarshallRequiredField( item, 'createdAt', ['_ct'], (v) => new Date(v) ), expires: unmarshallRequiredField( item, 'expires', ['ttl'], (v) => new Date(v * 1000) ), id: Base64.encode(`UserSession:${item.pk}`), session: unmarshallRequiredField(item, 'session', ['session', 'session']), sessionId: unmarshallRequiredField(item, 'sessionId', [ 'session_id', 'sessionId', ]), updatedAt: unmarshallRequiredField( item, 'updatedAt', ['_md'], (v) => new Date(v) ), version: unmarshallRequiredField(item, 'version', ['_v']), }; return result; }