Skip to content

Commit fd94418

Browse files
authored
feat(pg): add exportSchemas function for DDL generation (#11448)
## Description Add `exportSchemas()` function that exports Mastra database schema as SQL DDL statements without requiring a database connection. This is useful for: - Generating migration scripts - Reviewing the schema before deployment - Creating database schemas in environments where the application doesn't have CREATE privileges Also refactored internal code to eliminate duplication: - Extracted `generateTableSQL()` function from `createTable()` method - Consolidated SQL type mapping into a single `mapToSqlType()` function ## Related Issue(s) <!-- None - this is a new feature --> ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [x] Code refactoring - [ ] Performance improvement - [ ] Test update ## Checklist - [ ] I have made corresponding changes to the documentation (if applicable) - [x] I have added tests that prove my fix is effective or that my feature works <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Export database schemas as SQL DDL without a DB connection; optional schema name for targeted exports (emits CREATE SCHEMA if needed). * Centralized SQL type handling for consistent DDL (including timezone-aware timestamp columns). * Schema export function exposed in the public storage API with usage examples. * **Tests** * Added tests validating exported SQL for default and custom schemas, constraint names, timestamps, and invalid/allowed schema names. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent d7887bf commit fd94418

File tree

4 files changed

+200
-70
lines changed

4 files changed

+200
-70
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@mastra/pg": patch
3+
---
4+
5+
Added `exportSchemas()` function to generate Mastra database schema as SQL DDL without a database connection.
6+
7+
**What's New**
8+
9+
You can now export your Mastra database schema as SQL DDL statements without connecting to a database. This is useful for:
10+
11+
- Generating migration scripts
12+
- Reviewing the schema before deployment
13+
- Creating database schemas in environments where the application doesn't have CREATE privileges
14+
15+
**Example**
16+
17+
```typescript
18+
import { exportSchemas } from '@mastra/pg';
19+
20+
// Export schema for default 'public' schema
21+
const ddl = exportSchemas();
22+
console.log(ddl);
23+
24+
// Export schema for a custom schema
25+
const customDdl = exportSchemas('my_schema');
26+
// Creates: CREATE SCHEMA IF NOT EXISTS "my_schema"; and all tables within it
27+
```

stores/pg/src/storage/db/index.ts

Lines changed: 115 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,118 @@ function getTableName({ indexName, schemaName }: { indexName: string; schemaName
152152
return quotedSchemaName ? `${quotedSchemaName}.${quotedIndexName}` : quotedIndexName;
153153
}
154154

155+
function mapToSqlType(type: StorageColumn['type']): string {
156+
switch (type) {
157+
case 'uuid':
158+
return 'UUID';
159+
case 'boolean':
160+
return 'BOOLEAN';
161+
default:
162+
return getSqlType(type);
163+
}
164+
}
165+
166+
function generateTableSQL({
167+
tableName,
168+
schema,
169+
schemaName,
170+
}: {
171+
tableName: TABLE_NAMES;
172+
schema: Record<string, StorageColumn>;
173+
schemaName?: string;
174+
}): string {
175+
const timeZColumns = Object.entries(schema)
176+
.filter(([_, def]) => def.type === 'timestamp')
177+
.map(([name]) => {
178+
const parsedName = parseSqlIdentifier(name, 'column name');
179+
return `"${parsedName}Z" TIMESTAMPTZ DEFAULT NOW()`;
180+
});
181+
182+
const columns = Object.entries(schema).map(([name, def]) => {
183+
const parsedName = parseSqlIdentifier(name, 'column name');
184+
const constraints = [];
185+
if (def.primaryKey) constraints.push('PRIMARY KEY');
186+
if (!def.nullable) constraints.push('NOT NULL');
187+
return `"${parsedName}" ${mapToSqlType(def.type)} ${constraints.join(' ')}`;
188+
});
189+
190+
const finalColumns = [...columns, ...timeZColumns].join(',\n');
191+
// Sanitize schema name before using it in constraint names to ensure valid SQL identifiers
192+
const parsedSchemaName = schemaName ? parseSqlIdentifier(schemaName, 'schema name') : '';
193+
const constraintPrefix = parsedSchemaName ? `${parsedSchemaName}_` : '';
194+
const quotedSchemaName = getSchemaName(schemaName);
195+
196+
const sql = `
197+
CREATE TABLE IF NOT EXISTS ${getTableName({ indexName: tableName, schemaName: quotedSchemaName })} (
198+
${finalColumns}
199+
);
200+
${
201+
tableName === TABLE_WORKFLOW_SNAPSHOT
202+
? `
203+
DO $$ BEGIN
204+
IF NOT EXISTS (
205+
SELECT 1 FROM pg_constraint WHERE conname = '${constraintPrefix}mastra_workflow_snapshot_workflow_name_run_id_key'
206+
) AND NOT EXISTS (
207+
SELECT 1 FROM pg_indexes WHERE indexname = '${constraintPrefix}mastra_workflow_snapshot_workflow_name_run_id_key'
208+
) THEN
209+
ALTER TABLE ${getTableName({ indexName: tableName, schemaName: quotedSchemaName })}
210+
ADD CONSTRAINT ${constraintPrefix}mastra_workflow_snapshot_workflow_name_run_id_key
211+
UNIQUE (workflow_name, run_id);
212+
END IF;
213+
END $$;
214+
`
215+
: ''
216+
}
217+
${
218+
tableName === TABLE_SPANS
219+
? `
220+
DO $$ BEGIN
221+
IF NOT EXISTS (
222+
SELECT 1 FROM pg_constraint WHERE conname = '${constraintPrefix}mastra_ai_spans_traceid_spanid_pk'
223+
) THEN
224+
ALTER TABLE ${getTableName({ indexName: tableName, schemaName: quotedSchemaName })}
225+
ADD CONSTRAINT ${constraintPrefix}mastra_ai_spans_traceid_spanid_pk
226+
PRIMARY KEY ("traceId", "spanId");
227+
END IF;
228+
END $$;
229+
`
230+
: ''
231+
}
232+
`;
233+
234+
return sql;
235+
}
236+
237+
/**
238+
* Exports the Mastra database schema as SQL DDL statements.
239+
* Does not require a database connection.
240+
*/
241+
export function exportSchemas(schemaName?: string): string {
242+
const statements: string[] = [];
243+
244+
// Add schema creation if needed
245+
if (schemaName) {
246+
const quotedSchemaName = getSchemaName(schemaName);
247+
statements.push(`-- Create schema if it doesn't exist`);
248+
statements.push(`CREATE SCHEMA IF NOT EXISTS ${quotedSchemaName};`);
249+
statements.push('');
250+
}
251+
252+
// Generate SQL for all tables
253+
for (const [tableName, schema] of Object.entries(TABLE_SCHEMAS)) {
254+
statements.push(`-- Table: ${tableName}`);
255+
const sql = generateTableSQL({
256+
tableName: tableName as TABLE_NAMES,
257+
schema,
258+
schemaName,
259+
});
260+
statements.push(sql.trim());
261+
statements.push('');
262+
}
263+
264+
return statements.join('\n');
265+
}
266+
155267
/**
156268
* Internal config for PgDB - accepts already-resolved client
157269
*/
@@ -310,17 +422,6 @@ export class PgDB extends MastraBase {
310422
await registryEntry!.promise;
311423
}
312424

313-
protected getSqlType(type: StorageColumn['type']): string {
314-
switch (type) {
315-
case 'uuid':
316-
return 'UUID';
317-
case 'boolean':
318-
return 'BOOLEAN';
319-
default:
320-
return getSqlType(type);
321-
}
322-
}
323-
324425
protected getDefaultValue(type: StorageColumn['type']): string {
325426
switch (type) {
326427
case 'timestamp':
@@ -404,66 +505,11 @@ export class PgDB extends MastraBase {
404505
.filter(([_, def]) => def.type === 'timestamp')
405506
.map(([name]) => name);
406507

407-
const timeZColumns = Object.entries(schema)
408-
.filter(([_, def]) => def.type === 'timestamp')
409-
.map(([name]) => {
410-
const parsedName = parseSqlIdentifier(name, 'column name');
411-
return `"${parsedName}Z" TIMESTAMPTZ DEFAULT NOW()`;
412-
});
413-
414-
const columns = Object.entries(schema).map(([name, def]) => {
415-
const parsedName = parseSqlIdentifier(name, 'column name');
416-
const constraints = [];
417-
if (def.primaryKey) constraints.push('PRIMARY KEY');
418-
if (!def.nullable) constraints.push('NOT NULL');
419-
return `"${parsedName}" ${this.getSqlType(def.type)} ${constraints.join(' ')}`;
420-
});
421-
422508
if (this.schemaName) {
423509
await this.setupSchema();
424510
}
425511

426-
const finalColumns = [...columns, ...timeZColumns].join(',\n');
427-
const constraintPrefix = this.schemaName ? `${this.schemaName}_` : '';
428-
const schemaName = getSchemaName(this.schemaName);
429-
430-
const sql = `
431-
CREATE TABLE IF NOT EXISTS ${getTableName({ indexName: tableName, schemaName })} (
432-
${finalColumns}
433-
);
434-
${
435-
tableName === TABLE_WORKFLOW_SNAPSHOT
436-
? `
437-
DO $$ BEGIN
438-
IF NOT EXISTS (
439-
SELECT 1 FROM pg_constraint WHERE conname = '${constraintPrefix}mastra_workflow_snapshot_workflow_name_run_id_key'
440-
) AND NOT EXISTS (
441-
SELECT 1 FROM pg_indexes WHERE indexname = '${constraintPrefix}mastra_workflow_snapshot_workflow_name_run_id_key'
442-
) THEN
443-
ALTER TABLE ${getTableName({ indexName: tableName, schemaName })}
444-
ADD CONSTRAINT ${constraintPrefix}mastra_workflow_snapshot_workflow_name_run_id_key
445-
UNIQUE (workflow_name, run_id);
446-
END IF;
447-
END $$;
448-
`
449-
: ''
450-
}
451-
${
452-
tableName === TABLE_SPANS
453-
? `
454-
DO $$ BEGIN
455-
IF NOT EXISTS (
456-
SELECT 1 FROM pg_constraint WHERE conname = '${constraintPrefix}mastra_ai_spans_traceid_spanid_pk'
457-
) THEN
458-
ALTER TABLE ${getTableName({ indexName: tableName, schemaName: getSchemaName(this.schemaName) })}
459-
ADD CONSTRAINT ${constraintPrefix}mastra_ai_spans_traceid_spanid_pk
460-
PRIMARY KEY ("traceId", "spanId");
461-
END IF;
462-
END $$;
463-
`
464-
: ''
465-
}
466-
`;
512+
const sql = generateTableSQL({ tableName, schema, schemaName: this.schemaName });
467513

468514
await this.client.none(sql);
469515

@@ -547,7 +593,7 @@ export class PgDB extends MastraBase {
547593
const columnExists = await this.hasColumn(TABLE_SPANS, columnName);
548594
if (!columnExists) {
549595
const parsedColumnName = parseSqlIdentifier(columnName, 'column name');
550-
const sqlType = this.getSqlType(columnDef.type);
596+
const sqlType = mapToSqlType(columnDef.type);
551597
// Align with createTable: nullable columns omit NOT NULL, non-nullable columns include it
552598
const nullable = columnDef.nullable ? '' : 'NOT NULL';
553599
const defaultValue = !columnDef.nullable ? this.getDefaultValue(columnDef.type) : '';
@@ -612,7 +658,7 @@ export class PgDB extends MastraBase {
612658
if (schema[columnName]) {
613659
const columnDef = schema[columnName];
614660
const parsedColumnName = parseSqlIdentifier(columnName, 'column name');
615-
const sqlType = this.getSqlType(columnDef.type);
661+
const sqlType = mapToSqlType(columnDef.type);
616662
// Align with createTable: nullable columns omit NOT NULL, non-nullable columns include it
617663
const nullable = columnDef.nullable ? '' : 'NOT NULL';
618664
const defaultValue = !columnDef.nullable ? this.getDefaultValue(columnDef.type) : '';

stores/pg/src/storage/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ const DEFAULT_MAX_CONNECTIONS = 20;
2424
/** Default idle timeout in milliseconds */
2525
const DEFAULT_IDLE_TIMEOUT_MS = 30000;
2626

27+
export { exportSchemas } from './db';
28+
// Export domain classes for direct use with MastraStorage composition
2729
export { AgentsPG, MemoryPG, ObservabilityPG, ScoresPG, WorkflowsPG };
2830
export { PoolAdapter } from './client';
2931
export type { DbClient, TxClient, QueryValues, Pool, PoolClient, QueryResult } from './client';

stores/pg/src/storage/test-utils.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from
55
import type { PostgresStoreConfig } from '../shared/config';
66
import { PgDB } from './db';
77
import { MemoryPG } from './domains/memory';
8-
import { PostgresStore } from '.';
8+
import { exportSchemas, PostgresStore } from '.';
99

1010
export const TEST_CONFIG: PostgresStoreConfig = {
1111
id: 'test-postgres-store',
@@ -661,5 +661,60 @@ export function pgTests() {
661661
}
662662
});
663663
});
664+
665+
describe('Schema Export', () => {
666+
it('should export schema for public schema', () => {
667+
const schema = exportSchemas();
668+
669+
expect(schema).toContain('CREATE TABLE IF NOT EXISTS');
670+
expect(schema).toContain('mastra_threads');
671+
expect(schema).toContain('mastra_messages');
672+
expect(schema).toContain('mastra_workflow_snapshot');
673+
expect(schema).toContain('mastra_scorers');
674+
expect(schema).toContain('mastra_ai_spans');
675+
expect(schema).toContain('mastra_traces');
676+
expect(schema).toContain('mastra_resources');
677+
expect(schema).toContain('mastra_agents');
678+
});
679+
680+
it('should export schema with custom schema name', () => {
681+
const schema = exportSchemas('my_custom_schema');
682+
683+
expect(schema).toContain('CREATE SCHEMA IF NOT EXISTS "my_custom_schema"');
684+
expect(schema).toContain('"my_custom_schema"."mastra_threads"');
685+
expect(schema).toContain('"my_custom_schema"."mastra_messages"');
686+
687+
// Verify constraint names include the schema prefix
688+
expect(schema).toContain('my_custom_schema_mastra_workflow_snapshot_workflow_name_run_id_key');
689+
expect(schema).toContain('my_custom_schema_mastra_ai_spans_traceid_spanid_pk');
690+
});
691+
692+
it('should generate SQL with correct constraints', () => {
693+
const schema = exportSchemas();
694+
695+
expect(schema).toContain('createdAtZ" TIMESTAMPTZ DEFAULT NOW()');
696+
expect(schema).toContain('updatedAtZ" TIMESTAMPTZ DEFAULT NOW()');
697+
expect(schema).toContain('mastra_workflow_snapshot_workflow_name_run_id_key');
698+
expect(schema).toContain('UNIQUE (workflow_name, run_id)');
699+
expect(schema).toContain('mastra_ai_spans_traceid_spanid_pk');
700+
expect(schema).toContain('PRIMARY KEY ("traceId", "spanId")');
701+
});
702+
703+
it('should reject invalid schema names', () => {
704+
// Schema names with special characters should throw an error
705+
expect(() => exportSchemas('my-schema')).toThrow('Invalid schema name');
706+
expect(() => exportSchemas('123schema')).toThrow('Invalid schema name');
707+
expect(() => exportSchemas('schema with spaces')).toThrow('Invalid schema name');
708+
});
709+
710+
it('should accept valid schema names with underscores', () => {
711+
const schema = exportSchemas('my_schema');
712+
713+
// Valid schema name should work
714+
expect(schema).toContain('"my_schema"."mastra_threads"');
715+
expect(schema).toContain('my_schema_mastra_workflow_snapshot_workflow_name_run_id_key');
716+
expect(schema).toContain('my_schema_mastra_ai_spans_traceid_spanid_pk');
717+
});
718+
});
664719
});
665720
}

0 commit comments

Comments
 (0)