├── .gitignore ├── tests ├── migrations │ ├── 00006_delete.sql │ ├── 00003_delete │ │ └── index.sql │ ├── 00004_create.sql │ ├── 00001_initial │ │ └── index.sql │ ├── 00005_update.js │ └── 00002_update │ │ └── index.js └── index.js ├── index.d.ts ├── package.json ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /tests/migrations/00006_delete.sql: -------------------------------------------------------------------------------- 1 | drop table test; 2 | -------------------------------------------------------------------------------- /tests/migrations/00003_delete/index.sql: -------------------------------------------------------------------------------- 1 | drop table test; 2 | -------------------------------------------------------------------------------- /tests/migrations/00004_create.sql: -------------------------------------------------------------------------------- 1 | create table test ( 2 | a text, 3 | b int 4 | ) 5 | -------------------------------------------------------------------------------- /tests/migrations/00001_initial/index.sql: -------------------------------------------------------------------------------- 1 | create table test ( 2 | a text, 3 | b int 4 | ) 5 | -------------------------------------------------------------------------------- /tests/migrations/00005_update.js: -------------------------------------------------------------------------------- 1 | export default async function(sql) { 2 | await sql` 3 | alter table test add column c timestamp with time zone 4 | ` 5 | 6 | await sql` 7 | insert into test (a, b, c) values ('hello', 9, ${ new Date() }) 8 | ` 9 | } 10 | -------------------------------------------------------------------------------- /tests/migrations/00002_update/index.js: -------------------------------------------------------------------------------- 1 | export default async function(sql) { 2 | await sql` 3 | alter table test add column c timestamp with time zone 4 | ` 5 | 6 | await sql` 7 | insert into test (a, b, c) values ('hello', 9, ${ new Date() }) 8 | ` 9 | } 10 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { Sql } from 'postgres'; 2 | 3 | type Migration = { 4 | path: string; 5 | migration_id: number; 6 | name: string; 7 | }; 8 | export default function migrationRunner({ sql, path, before, after }: { 9 | sql: Sql; 10 | path: string; 11 | before?: (m: Migration) => void; 12 | after?: (m: Migration) => void; 13 | }): Promise; 14 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | import shift from '../index.js' 2 | import postgres from 'postgres' 3 | import cp from 'child_process' 4 | import { fileURLToPath } from 'url' 5 | 6 | const db = 'postgres_shift_test' 7 | cp.execSync('dropdb ' + db + ';createdb ' + db) 8 | 9 | const sql = postgres({ 10 | db, 11 | idle_timeout: 1 12 | }) 13 | 14 | shift({ 15 | sql, 16 | path: fileURLToPath(new URL('migrations', import.meta.url)), 17 | before: ({ 18 | migration_id, 19 | name 20 | }) => { 21 | console.log('Migrating', migration_id, name) 22 | } 23 | }) 24 | .then(() => console.log('All good')) 25 | .catch(err => { 26 | console.error('Failed', err) 27 | process.exit(1) 28 | }) 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postgres-shift", 3 | "version": "0.1.0", 4 | "description": "A simple forwards only migration solution for [postgres.js](https://github.com/porsager/postgres)", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "node tests/index.js" 9 | }, 10 | "files": [ 11 | "/index.js" 12 | ], 13 | "author": "Rasmus Porsager ", 14 | "license": "WTFPL", 15 | "repository": "porsager/postgres-shift", 16 | "keywords": [ 17 | "migration", 18 | "driver", 19 | "postgresql", 20 | "postgres.js", 21 | "postgres", 22 | "postgre", 23 | "client", 24 | "sql", 25 | "db", 26 | "pg", 27 | "database" 28 | ], 29 | "devDependencies": { 30 | "postgres": "3.3.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `postgres-shift` 2 | 3 | A simple forwards only migration solution for [postgres.js](https://github.com/porsager/postgres). 4 | 5 | `postgres-shift` 6 | 7 | ## `shift()` 8 | `postgres-shift` exports a single function which is used to start migrations. 9 | It expects a [postgres.js sql]() database object to run migrations with. 10 | 11 | ## `Example` 12 | 13 | 1. Create `migrations` directory with following structure 14 | ``` 15 | . 16 | └── migrations 17 | ├── 00001_initial 18 | │   └── index.sql 19 | ├── 00002_update 20 | │   └── index.js 21 | └── 00003_delete 22 | └── index.sql 23 | ``` 24 | Note that migration folder name expects 5 digits. This is very important 25 | detail. 26 | 27 | 2. Create actual migrations. Note that it can be either in SQL or JS syntax 28 | ```sql 29 | create table test ( 30 | a text, 31 | b int 32 | ) 33 | ``` 34 | ```js 35 | export default async function(sql) { 36 | await sql` 37 | alter table test add column c timestamp with time zone 38 | ` 39 | 40 | await sql` 41 | insert into test (a, b, c) values ('hello', 9, ${ new Date() }) 42 | ` 43 | } 44 | ``` 45 | 46 | 3. Create your `migrate.js` script: 47 | ```js 48 | import shift from 'postgres-shift'; 49 | import postgres from 'postgres'; 50 | import { fileURLToPath } from 'url'; 51 | 52 | const { 53 | DB_HOST, 54 | DB_PORT, 55 | DB_NAME, 56 | ADMIN_DB_USER, 57 | ADMIN_DB_PASSWORD, 58 | } = process.env; 59 | 60 | export const sql = postgres({ 61 | host: DB_HOST, 62 | port: Number(DB_PORT), 63 | database: DB_NAME, 64 | user: ADMIN_DB_USER, 65 | pass: ADMIN_DB_PASSWORD, 66 | idle_timeout: 1, 67 | }); 68 | 69 | shift({ 70 | sql, 71 | path: fileURLToPath(new URL('migrations', import.meta.url)), 72 | before: ({ migration_id, name }) => { 73 | console.log('Migrating', migration_id, name); 74 | }, 75 | }) 76 | .then(() => console.log('All good')) 77 | .catch((err) => { 78 | console.error('Failed', err); 79 | process.exit(1); 80 | }); 81 | ``` 82 | 83 | 4. Run it with 84 | ```console 85 | node ./migrate.js 86 | ``` 87 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | const join = path.join 5 | 6 | export default async function({ 7 | sql, 8 | path = join(process.cwd(), 'migrations'), 9 | before = null, 10 | after = null 11 | }) { 12 | const migrations = fs.readdirSync(path) 13 | .filter(x => (fs.statSync(join(path, x)).isDirectory() || fs.statSync(join(path, x)).isFile()) && x.match(/^[0-9]{5}_/)) 14 | .sort() 15 | .map(x => ({ 16 | path: join(path, x), 17 | migration_id: parseInt(x.slice(0, 5)), 18 | name: x.slice(6).replace(/-/g, ' ') 19 | })) 20 | 21 | const latest = migrations[migrations.length - 1] 22 | 23 | if (latest.migration_id !== migrations.length) 24 | throw new Error('Inconsistency in migration numbering') 25 | 26 | await ensureMigrationsTable() 27 | 28 | const current = await getCurrentMigration() 29 | const needed = migrations.slice(current ? current.id : 0) 30 | 31 | return sql.begin(next) 32 | 33 | async function next(sql) { 34 | const current = needed.shift() 35 | if (!current) 36 | return 37 | 38 | before && before(current) 39 | await run(sql, current) 40 | after && after(current) 41 | await next(sql) 42 | } 43 | 44 | async function run(sql, { 45 | path, 46 | migration_id, 47 | name 48 | }) { 49 | if (fs.statSync(path).isFile()) { 50 | path.endsWith('.sql') 51 | ? await sql.file(path) 52 | : await import(path).then(x => x.default(sql)) // eslint-disable-line 53 | } else if (fs.statSync(path).isDirectory()) { 54 | fs.existsSync(join(path, 'index.sql')) && !fs.existsSync(join(path, 'index.js')) 55 | ? await sql.file(join(path, 'index.sql')) 56 | : await import(join(path, 'index.js')).then(x => x.default(sql)) // eslint-disable-line 57 | } 58 | await sql` 59 | insert into migrations ( 60 | migration_id, 61 | name 62 | ) values ( 63 | ${ migration_id }, 64 | ${ name } 65 | ) 66 | ` 67 | } 68 | 69 | function getCurrentMigration() { 70 | return sql` 71 | select migration_id as id from migrations 72 | order by migration_id desc 73 | limit 1 74 | `.then(([x]) => x) 75 | } 76 | 77 | function ensureMigrationsTable() { 78 | return sql` 79 | select 'migrations'::regclass 80 | `.catch((err) => sql` 81 | create table migrations ( 82 | migration_id serial primary key, 83 | created_at timestamp with time zone not null default now(), 84 | name text 85 | ) 86 | `) 87 | } 88 | 89 | } 90 | --------------------------------------------------------------------------------