Production-ready MySQL storage adapter for LTI 1.3. Includes caching and optimized for AWS Lambda.
npm install @lti-tool/mysql
import { MySqlStorage } from '@lti-tool/mysql';
import { LTITool } from '@lti-tool/core';
const storage = new MySqlStorage({
connectionUrl: process.env.DATABASE_URL!,
});
const ltiTool = new LTITool({
storage,
// ... other config
});
# Set your DATABASE_URL
export DATABASE_URL="mysql://user:password@host:port/database"
# Push schema to database
npx drizzle-kit push
# Apply migrations
npx drizzle-kit migrate
connectionUrl (required): MySQL connection URL
Format: mysql://user:password@host:port/database
poolOptions (optional): mysql2 pool configuration
connectionLimit: Max connections (auto: 1 for serverless, 10 for servers)queueLimit: Max queued requests (default: 0 = unlimited)nonceExpirationSeconds (optional): Nonce TTL in seconds (default: 600)
logger (optional): Pino logger for debugging
The adapter uses these tables:
(iss, clientId)(clientId, deploymentId)expiresAtnonce
Indexed: expiresAtexpiresAtAll tables use UUIDs for primary keys and include indexes for performance.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
VARCHAR(36) | PRIMARY KEY, NOT NULL | Internal UUID for the client |
name |
VARCHAR(255) | NOT NULL | Human-readable platform name |
iss |
VARCHAR(255) | NOT NULL | Issuer URL (LMS platform) |
clientId |
VARCHAR(255) | NOT NULL | LMS-provided client identifier |
authUrl |
TEXT | NOT NULL | OAuth2 authorization endpoint |
tokenUrl |
TEXT | NOT NULL | OAuth2 token endpoint |
jwksUrl |
TEXT | NOT NULL | JWKS endpoint for public keys |
Indexes:
issuer_client_idx: (clientId, iss) - For fast client lookupsiss_client_id_unique: (iss, clientId) - Unique constraint preventing duplicate clients| Column | Type | Constraints | Description |
|---|---|---|---|
id |
VARCHAR(36) | PRIMARY KEY, NOT NULL | Internal UUID for the deployment |
deploymentId |
VARCHAR(255) | NOT NULL | LMS-provided deployment identifier |
name |
VARCHAR(255) | NULL | Optional human-readable name |
description |
TEXT | NULL | Optional description |
clientId |
VARCHAR(36) | NOT NULL, FOREIGN KEY | References clients.id |
Indexes:
deployment_id_idx: (deploymentId) - For fast deployment lookupsclient_deployment_unique: (clientId, deploymentId) - Unique constraint per client| Column | Type | Constraints | Description |
|---|---|---|---|
id |
VARCHAR(36) | PRIMARY KEY, NOT NULL | Session UUID |
data |
JSON | NOT NULL | Complete LTI session data |
expiresAt |
DATETIME | NOT NULL | Session expiration timestamp |
Indexes:
expires_at_idx: (expiresAt) - For cleanup queries and expiration checks| Column | Type | Constraints | Description |
|---|---|---|---|
nonce |
VARCHAR(255) | PRIMARY KEY, NOT NULL | One-time use nonce value |
expiresAt |
DATETIME | NOT NULL | Nonce expiration timestamp |
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
VARCHAR(36) | PRIMARY KEY, NOT NULL | Registration session UUID |
data |
JSON | NOT NULL | Dynamic registration session data |
expiresAt |
DATETIME | NOT NULL | Session expiration timestamp |
Indexes:
expires_at_idx: (expiresAt) - For cleanup queries and expiration checksThe adapter automatically detects your deployment environment:
connectionLimit: 1connectionLimit: 10connectionLimit based on concurrent loadconst storage = new MySqlStorage({
connectionUrl: process.env.DATABASE_URL!,
poolOptions: {
connectionLimit: 20, // Override auto-detection
},
});
import { MySqlStorage } from '@lti-tool/mysql';
export const storage = new MySqlStorage({
connectionUrl: process.env.DATABASE_URL!,
});
// Optional: Graceful shutdown
const shutdown = async () => {
await storage.close();
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
Connection Limits:
5-10 connections10-20 connections20-50 connectionsmax_connectionsimport { MySqlStorage } from '@lti-tool/mysql';
let storage: MySqlStorage | undefined;
export const handler = async (event) => {
if (!storage) {
storage = new MySqlStorage({
connectionUrl: process.env.DATABASE_URL!,
// Auto-detects Lambda, uses connectionLimit: 1
});
}
// Use storage...
};
Why connectionLimit: 1?
Lambda containers handle one request at a time. The connection is reused across warm invocations.
Do I need close()?
No! Lambda freezes containers efficiently. Calling close() destroys reusable connections.
⚠️ Not supported!
The adapter requires periodic cleanup of expired nonces and sessions.
// Example - AWS Lambda with EventBridge (every 30 minutes)
export const handler = async () => {
const result = await storage.cleanup();
console.log('Cleanup:', result);
// { noncesDeleted: 42, sessionsDeleted: 15, registrationSessionsDeleted: 3 }
};
# Using Docker
docker-compose up -d
# Using Podman
podman-compose up -d
# Or Podman directly
podman run -d \
--name lti-mysql \
-e MYSQL_ROOT_PASSWORD=root \
-e MYSQL_DATABASE=lti_test \
-e MYSQL_USER=lti_user \
-e MYSQL_PASSWORD=lti_password \
-p 3306:3306 \
mysql:8.0
DATABASE_URL="mysql://lti_user:lti_password@localhost:3306/lti_test" npm test
Important: Always close the pool(s) after tests:
afterAll(async () => {
// close the drizzle pool
await storage.close();
// close the vitest pool
await pool.end();
});
Auto-detects serverless by checking:
AWS_LAMBDA_FUNCTION_NAME, AWS_EXECUTION_ENVFUNCTION_NAME, K_SERVICEFUNCTIONS_WORKER_RUNTIMEVERCELNETLIFY