@shopify/shopify-app-session-storage-prisma
Version:
Shopify App Session Storage for Prisma
269 lines (228 loc) • 7.54 kB
text/typescript
import {Session} from '@shopify/shopify-api';
import {SessionStorage} from '@shopify/shopify-app-session-storage';
import type {PrismaClient, Session as Row} from '@prisma/client';
import {Prisma} from '@prisma/client';
interface PrismaSessionStorageOptions {
tableName?: string;
connectionRetries?: number;
connectionRetryIntervalMs?: number;
}
const UNIQUE_KEY_CONSTRAINT_ERROR_CODE = 'P2002';
// eslint-disable-next-line no-warning-comments
// TODO: Remove this when all session storages have implemented the isReady method
export interface PrismaSessionStorageInterface extends SessionStorage {
isReady(): Promise<boolean>;
}
export class PrismaSessionStorage<
T extends PrismaClient,
> implements PrismaSessionStorageInterface {
private ready: Promise<boolean>;
private readonly tableName: string = 'session';
private connectionRetries = 2;
private connectionRetryIntervalMs = 5000;
constructor(
private prisma: T,
{
tableName,
connectionRetries,
connectionRetryIntervalMs,
}: PrismaSessionStorageOptions = {},
) {
if (tableName) {
this.tableName = tableName;
}
if (connectionRetries !== undefined) {
this.connectionRetries = connectionRetries;
}
if (connectionRetryIntervalMs !== undefined) {
this.connectionRetryIntervalMs = connectionRetryIntervalMs;
}
if (this.getSessionTable() === undefined) {
throw new Error(`PrismaClient does not have a ${this.tableName} table`);
}
this.ready = this.pollForTable()
.then(() => true)
.catch((cause) => {
throw new MissingSessionTableError(
`Prisma ${this.tableName} table does not exist. This could happen for a few reasons, see https://github.com/Shopify/shopify-app-js/tree/main/packages/apps/session-storage/shopify-app-session-storage-prisma#troubleshooting for more information`,
cause,
);
});
}
public async storeSession(session: Session): Promise<boolean> {
await this.ensureReady();
const data = this.sessionToRow(session);
try {
await this.getSessionTable().upsert({
where: {id: session.id},
update: data,
create: data,
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === UNIQUE_KEY_CONSTRAINT_ERROR_CODE
) {
console.log(
'Caught PrismaClientKnownRequestError P2002 - Unique Key Key Constraint, retrying upsert.',
);
await this.getSessionTable().upsert({
where: {id: session.id},
update: data,
create: data,
});
return true;
}
throw error;
}
return true;
}
public async loadSession(id: string): Promise<Session | undefined> {
await this.ensureReady();
const row = await this.getSessionTable().findUnique({
where: {id},
});
if (!row) {
return undefined;
}
return this.rowToSession(row);
}
public async deleteSession(id: string): Promise<boolean> {
await this.ensureReady();
try {
await this.getSessionTable().delete({where: {id}});
} catch {
return true;
}
return true;
}
public async deleteSessions(ids: string[]): Promise<boolean> {
await this.ensureReady();
await this.getSessionTable().deleteMany({where: {id: {in: ids}}});
return true;
}
public async findSessionsByShop(shop: string): Promise<Session[]> {
await this.ensureReady();
const sessions = await this.getSessionTable().findMany({
where: {shop},
take: 25,
orderBy: [{expires: 'desc'}],
});
return sessions.map((session) => this.rowToSession(session));
}
public async isReady(): Promise<boolean> {
try {
await this.pollForTable();
this.ready = Promise.resolve(true);
} catch (_error) {
this.ready = Promise.resolve(false);
}
return this.ready;
}
private async ensureReady(): Promise<void> {
if (!(await this.ready))
throw new MissingSessionStorageError(
'Prisma session storage is not ready. Use the `isReady` method to poll for the table.',
);
}
private async pollForTable(): Promise<void> {
for (let i = 0; i < this.connectionRetries; i++) {
try {
await this.getSessionTable().count();
return;
} catch (error) {
console.log(`Error obtaining session table: ${error}`);
}
await sleep(this.connectionRetryIntervalMs);
}
throw Error(
`The table \`${this.tableName}\` does not exist in the current database.`,
);
}
private sessionToRow(session: Session): Row {
const sessionParams = session.toObject();
return {
id: session.id,
shop: session.shop,
state: session.state,
isOnline: session.isOnline,
scope: session.scope || null,
expires: session.expires || null,
accessToken: session.accessToken || '',
userId:
(sessionParams.onlineAccessInfo?.associated_user
.id as unknown as bigint) || null,
firstName:
sessionParams.onlineAccessInfo?.associated_user.first_name || null,
lastName:
sessionParams.onlineAccessInfo?.associated_user.last_name || null,
email: sessionParams.onlineAccessInfo?.associated_user.email || null,
accountOwner:
sessionParams.onlineAccessInfo?.associated_user.account_owner || false,
locale: sessionParams.onlineAccessInfo?.associated_user.locale || null,
collaborator:
sessionParams.onlineAccessInfo?.associated_user.collaborator || false,
emailVerified:
sessionParams.onlineAccessInfo?.associated_user.email_verified || false,
refreshToken: sessionParams.refreshToken || null,
refreshTokenExpires: sessionParams.refreshTokenExpires || null,
};
}
private rowToSession(row: Row): Session {
const sessionParams: Record<string, boolean | string | number> = {
id: row.id,
shop: row.shop,
state: row.state,
isOnline: row.isOnline,
userId: String(row.userId),
firstName: String(row.firstName),
lastName: String(row.lastName),
email: String(row.email),
locale: String(row.locale),
};
if (row.accountOwner !== null) {
sessionParams.accountOwner = row.accountOwner;
}
if (row.collaborator !== null) {
sessionParams.collaborator = row.collaborator;
}
if (row.emailVerified !== null) {
sessionParams.emailVerified = row.emailVerified;
}
if (row.expires) {
sessionParams.expires = row.expires.getTime();
}
if (row.scope) {
sessionParams.scope = row.scope;
}
if (row.accessToken) {
sessionParams.accessToken = row.accessToken;
}
if (row.refreshToken) {
sessionParams.refreshToken = row.refreshToken;
}
if (row.refreshTokenExpires) {
sessionParams.refreshTokenExpires = row.refreshTokenExpires.getTime();
}
return Session.fromPropertyArray(Object.entries(sessionParams), true);
}
private getSessionTable(): T['session'] {
return (this.prisma as any)[this.tableName];
}
}
export class MissingSessionTableError extends Error {
constructor(
message: string,
public readonly cause: Error,
) {
super(message);
}
}
export class MissingSessionStorageError extends Error {
constructor(message: string) {
super(message);
}
}
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}