-
Notifications
You must be signed in to change notification settings - Fork 4
/
remodel.js
154 lines (131 loc) · 4.12 KB
/
remodel.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
import { basename, crypto, toHashString, walk } from './deps.ts'
const LOCK_ID = -800635800635800635n
async function hash(str) {
return toHashString(
await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(str),
),
)
}
export default async function remodel(pg, { migrations, table }) {
if (!table) {
throw new Error('remodel: migrations table name must be provided')
}
const META_MIGRATIONS = new Map([
[
`create_${table}`,
`CREATE TABLE ${table} (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT (NOW() at time zone 'UTC'),
hash TEXT NOT NULL
);`,
],
])
try {
try {
while (true) {
const [{ lock }] =
await pg`SELECT pg_try_advisory_lock(${LOCK_ID}) as lock`
if (lock) break
await new Promise((res) => setTimeout(res, 1000))
}
} catch (e) {
console.error(`remodel: error acquiring advisory lock ${e.message}`)
throw e
}
if (typeof migrations === 'string') {
migrations = await fromDir(migrations)
} else if (migrations instanceof Map === false) {
throw new Error(
'remodel: second arg to remodel must be a Map of name => migration' +
' or a directory path',
)
}
// add in the migration that creates the migrations table
migrations = new Map([...META_MIGRATIONS, ...migrations])
// hash them all
for (const [name, migration] of migrations) {
migrations.set(name, { migration, hash: await hash(name + migration) })
}
// get unapplied migrations
migrations = await unapplied(pg, migrations, table)
// apply them
for (const [name, { migration, hash }] of migrations) {
console.log(`db ${pg.options.database} applying migration ${name} ... `)
await pg.begin(async (pg) => {
await pg.unsafe(migration)
await pg`INSERT INTO ${pg.unsafe(table)} (name, hash)
VALUES (${name}, ${hash})`
})
console.log(
`\x1b[A\x1b[Kdb ${pg.options.database} applying migration ${name} ... applied`,
)
}
} catch (e) {
console.error(`remodel: error while using lock: ${e.message}`)
throw e
} finally {
try {
await pg`SELECT pg_advisory_unlock(${LOCK_ID})`
} catch (e) {
console.error(`remodel: error releasing advisory lock: ${e.message}`)
}
}
}
async function unapplied(pg, pending, table) {
const migrations = new Map(pending)
// get applied migrations if they exist
const [{ exists }] = await pg`SELECT EXISTS (
SELECT 1
FROM pg_catalog.pg_class c
WHERE c.relname = ${table}
AND c.relkind = 'r'
)`
const applied = exists
? await pg`SELECT name, hash FROM ${pg.unsafe(table)} ORDER BY id ASC`
: []
// any applied must exist in migrations, match hash and name, and be in order
// remove the applied migrations and apply the rest
const iter = migrations.entries()
for (const { name, hash } of applied) {
const { value: [mName, { hash: mHash }] } = iter.next()
if (mName !== name) {
if (migrations.has(name)) {
throw new Error(
'remodel: applied migrations are missing from the migrations' +
' passed in or are out of order',
)
} else {
throw new Error(
`remodel: ${name} is applied but was not found in migrations passed in`,
)
}
}
if (hash !== mHash) {
throw new Error(
`remodel: migration ${name} is applied but hash has changed`,
)
}
migrations.delete(name)
}
return migrations
}
async function fromDir(dir) {
let migrations = new Map()
for await (const entry of walk(dir, { exts: ['.sql'] })) {
const name = basename(entry.path, '.sql')
if (migrations.has(name)) {
throw new Error(`remodel: migration name collision on ${name}`)
}
migrations.set(name, entry.path)
}
migrations = new Map(
[...migrations].sort((a, b) => String(a[0]).localeCompare(b[0])),
)
for (const [name, path] of migrations) {
migrations.set(name, Deno.readTextFileSync(path))
}
return migrations
}