@sqldoc/db-neon-temporary 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +26 -0
- package/src/__tests__/neon-temporary.test.ts +69 -0
- package/src/index.ts +235 -0
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "@sqldoc/db-neon-temporary",
|
|
4
|
+
"version": "0.0.10",
|
|
5
|
+
"description": "Neon ephemeral database adapter for sqldoc (creates temporary databases via neon-new)",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"import": "./src/index.ts",
|
|
10
|
+
"default": "./src/index.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"main": "./src/index.ts",
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"package.json"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@neondatabase/serverless": "^1.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@sqldoc/db": "0.0.10",
|
|
24
|
+
"@sqldoc/test-utils": "0.0.1"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for @sqldoc/db-neon-temporary.
|
|
3
|
+
*
|
|
4
|
+
* Creates a real ephemeral Neon database via the Neon API.
|
|
5
|
+
* Only runs when TEST_NEON=true is set.
|
|
6
|
+
*
|
|
7
|
+
* Example: TEST_NEON=true bun test packages/db-neon-temporary/ --timeout 120000
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from '@sqldoc/test-utils'
|
|
10
|
+
import plugin from '../index.ts'
|
|
11
|
+
|
|
12
|
+
describe('neon-temporary adapter', () => {
|
|
13
|
+
if (process.env.TEST_NEON !== 'true') {
|
|
14
|
+
it('skipped: set TEST_NEON=true to run', () => {})
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
plugin.lockTimeoutMs = 5000
|
|
19
|
+
|
|
20
|
+
it('creates database, wipes schema, provides clean slate', async () => {
|
|
21
|
+
const adapter = await plugin.createAdapter('neon-temporary', { dialect: 'postgres', extensions: [] })
|
|
22
|
+
try {
|
|
23
|
+
// Schema should be empty after wipe
|
|
24
|
+
const result = await adapter.query(
|
|
25
|
+
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'",
|
|
26
|
+
)
|
|
27
|
+
expect(result.rows).toHaveLength(0)
|
|
28
|
+
|
|
29
|
+
// Can create tables
|
|
30
|
+
await adapter.exec('CREATE TABLE test_neon (id serial PRIMARY KEY, name text)')
|
|
31
|
+
const tables = await adapter.query(
|
|
32
|
+
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'",
|
|
33
|
+
)
|
|
34
|
+
expect(tables.rows).toHaveLength(1)
|
|
35
|
+
} finally {
|
|
36
|
+
await adapter.close()
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('reuses cached database on second call', async () => {
|
|
41
|
+
plugin.forceReuse = true
|
|
42
|
+
try {
|
|
43
|
+
const adapter = await plugin.createAdapter('neon-temporary', { dialect: 'postgres', extensions: [] })
|
|
44
|
+
try {
|
|
45
|
+
// Should connect without creating a new DB (reuses cache from previous test)
|
|
46
|
+
const result = await adapter.query('SELECT 1 as ok')
|
|
47
|
+
expect(result.rows[0][0]).toBe(1)
|
|
48
|
+
} finally {
|
|
49
|
+
await adapter.close()
|
|
50
|
+
}
|
|
51
|
+
} finally {
|
|
52
|
+
plugin.forceReuse = false
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('releases lock on close, allows next connection', async () => {
|
|
57
|
+
const adapter = await plugin.createAdapter('neon-temporary', { dialect: 'postgres', extensions: [] })
|
|
58
|
+
await adapter.close()
|
|
59
|
+
|
|
60
|
+
// Should be able to reconnect immediately (lock released)
|
|
61
|
+
const adapter2 = await plugin.createAdapter('neon-temporary', { dialect: 'postgres', extensions: [] })
|
|
62
|
+
try {
|
|
63
|
+
const result = await adapter2.query('SELECT 1 as ok')
|
|
64
|
+
expect(result.rows[0][0]).toBe(1)
|
|
65
|
+
} finally {
|
|
66
|
+
await adapter2.close()
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neon ephemeral database adapter for sqldoc.
|
|
3
|
+
*
|
|
4
|
+
* Creates a temporary Neon database on first use, caches the connection
|
|
5
|
+
* URL in .sqldoc/neon-temporary.json, and reuses it for 72 hours.
|
|
6
|
+
*
|
|
7
|
+
* Uses @neondatabase/serverless for the connection (WebSocket transport,
|
|
8
|
+
* works in Node, Bun, and edge environments).
|
|
9
|
+
*
|
|
10
|
+
* On each run:
|
|
11
|
+
* 1. Load or create the database (cached for 72h)
|
|
12
|
+
* 2. Acquire advisory lock (blocks if another sqldoc process is using it)
|
|
13
|
+
* 3. Wipe all schemas (clean slate)
|
|
14
|
+
* 4. Release lock on close
|
|
15
|
+
*
|
|
16
|
+
* devUrl: just 'neon-temporary' (no URL needed)
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from 'node:crypto'
|
|
19
|
+
import * as fs from 'node:fs'
|
|
20
|
+
import * as path from 'node:path'
|
|
21
|
+
import { Client } from '@neondatabase/serverless'
|
|
22
|
+
import type { AdapterPluginContext, DatabaseAdapter, DatabaseAdapterPlugin, ExecResult, QueryResult } from '@sqldoc/db'
|
|
23
|
+
import { normalizeValue } from '@sqldoc/db'
|
|
24
|
+
|
|
25
|
+
const log = (msg: string) => console.error(`[neon-temporary] ${msg}`)
|
|
26
|
+
|
|
27
|
+
/** Advisory lock key — "SqLD" in hex */
|
|
28
|
+
const LOCK_KEY = 0x5371_4c44
|
|
29
|
+
|
|
30
|
+
const NEON_API = 'https://neon.new/api/v1/database'
|
|
31
|
+
const NEON_CLAIM = 'https://neon.new/database'
|
|
32
|
+
const REFERRER = 'npm:neon-new|https://sqldoc.dev'
|
|
33
|
+
const CACHE_FILE = 'neon-temporary.json'
|
|
34
|
+
const MAX_AGE_MS = 72 * 60 * 60 * 1000
|
|
35
|
+
|
|
36
|
+
interface CachedDb {
|
|
37
|
+
directUrl: string
|
|
38
|
+
poolerUrl: string
|
|
39
|
+
claimUrl: string
|
|
40
|
+
createdAt: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Cache ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function getCachePath(): string | null {
|
|
46
|
+
let dir = process.cwd()
|
|
47
|
+
while (true) {
|
|
48
|
+
const candidate = path.join(dir, '.sqldoc')
|
|
49
|
+
if (fs.existsSync(candidate)) return path.join(candidate, CACHE_FILE)
|
|
50
|
+
const parent = path.dirname(dir)
|
|
51
|
+
if (parent === dir) return null
|
|
52
|
+
dir = parent
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadCache(): CachedDb | null {
|
|
57
|
+
const cachePath = getCachePath()
|
|
58
|
+
if (!cachePath || !fs.existsSync(cachePath)) return null
|
|
59
|
+
try {
|
|
60
|
+
const raw = JSON.parse(fs.readFileSync(cachePath, 'utf-8'))
|
|
61
|
+
if (
|
|
62
|
+
typeof raw?.directUrl !== 'string' ||
|
|
63
|
+
typeof raw?.poolerUrl !== 'string' ||
|
|
64
|
+
typeof raw?.claimUrl !== 'string' ||
|
|
65
|
+
typeof raw?.createdAt !== 'string'
|
|
66
|
+
) {
|
|
67
|
+
log('cache invalid: missing required fields')
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
const data: CachedDb = raw
|
|
71
|
+
const createdAtMs = new Date(data.createdAt).getTime()
|
|
72
|
+
if (Number.isNaN(createdAtMs)) {
|
|
73
|
+
log('cache invalid: bad createdAt timestamp')
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
const age = Date.now() - createdAtMs
|
|
77
|
+
if (age > MAX_AGE_MS) {
|
|
78
|
+
log(`cache expired (${Math.round(age / 3600000)}h old)`)
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
log(`reusing cached database (${Math.round(age / 60000)}m old)`)
|
|
82
|
+
return data
|
|
83
|
+
} catch {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function saveCache(data: CachedDb): void {
|
|
89
|
+
const cachePath = getCachePath()
|
|
90
|
+
if (!cachePath) return
|
|
91
|
+
fs.writeFileSync(cachePath, `${JSON.stringify(data, null, 2)}\n`)
|
|
92
|
+
log(`cached to ${cachePath}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Neon API ───────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function toPooler(connString: string): string {
|
|
98
|
+
const url = new URL(connString)
|
|
99
|
+
if (url.hostname.includes('pooler')) return connString
|
|
100
|
+
const [first, ...rest] = url.hostname.split('.')
|
|
101
|
+
url.hostname = [`${first}-pooler`, ...rest].join('.')
|
|
102
|
+
return url.href
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toDirect(connString: string): string {
|
|
106
|
+
const url = new URL(connString)
|
|
107
|
+
if (!url.hostname.includes('pooler')) return connString
|
|
108
|
+
const [first, ...rest] = url.hostname.split('.')
|
|
109
|
+
url.hostname = [first.replace('-pooler', ''), ...rest].join('.')
|
|
110
|
+
return url.href
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function createNeonDatabase(): Promise<CachedDb> {
|
|
114
|
+
log('creating new Neon database...')
|
|
115
|
+
|
|
116
|
+
const dbId = randomUUID()
|
|
117
|
+
const createUrl = `${NEON_API}/${dbId}?referrer=${encodeURIComponent(REFERRER)}`
|
|
118
|
+
|
|
119
|
+
log(`POST ${createUrl}`)
|
|
120
|
+
const createRes = await fetch(createUrl, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { 'Content-Type': 'application/json' },
|
|
123
|
+
body: JSON.stringify({ enable_logical_replication: false }),
|
|
124
|
+
})
|
|
125
|
+
if (!createRes.ok) {
|
|
126
|
+
throw new Error(`Failed to create Neon database: ${createRes.status} ${createRes.statusText}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
log(`GET ${NEON_API}/${dbId}`)
|
|
130
|
+
const dataRes = await fetch(`${NEON_API}/${dbId}`, {
|
|
131
|
+
headers: { 'Content-Type': 'application/json' },
|
|
132
|
+
})
|
|
133
|
+
if (!dataRes.ok) {
|
|
134
|
+
throw new Error(`Failed to fetch Neon database info: ${dataRes.status} ${dataRes.statusText}`)
|
|
135
|
+
}
|
|
136
|
+
const payload = (await dataRes.json()) as { connection_string?: string }
|
|
137
|
+
if (!payload.connection_string) {
|
|
138
|
+
throw new Error('Neon API response missing connection_string')
|
|
139
|
+
}
|
|
140
|
+
const { connection_string } = payload
|
|
141
|
+
|
|
142
|
+
const claimUrl = `${NEON_CLAIM}/${dbId}`
|
|
143
|
+
log(`database created, claim: ${claimUrl}`)
|
|
144
|
+
|
|
145
|
+
const data: CachedDb = {
|
|
146
|
+
directUrl: toDirect(connection_string),
|
|
147
|
+
poolerUrl: toPooler(connection_string),
|
|
148
|
+
claimUrl,
|
|
149
|
+
createdAt: new Date().toISOString(),
|
|
150
|
+
}
|
|
151
|
+
saveCache(data)
|
|
152
|
+
return data
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function getOrCreateDatabase(forceReuse: boolean): Promise<CachedDb> {
|
|
156
|
+
const cached = loadCache()
|
|
157
|
+
if (cached) return cached
|
|
158
|
+
if (forceReuse) throw new Error('neon-temporary: no cached database found (forceReuse=true)')
|
|
159
|
+
return createNeonDatabase()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Plugin ─────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
const plugin: DatabaseAdapterPlugin & { forceReuse: boolean; lockTimeoutMs: number } = {
|
|
165
|
+
apiVersion: 1,
|
|
166
|
+
name: 'neon-temporary',
|
|
167
|
+
schemes: ['neon-temporary'],
|
|
168
|
+
dialects: ['postgres'],
|
|
169
|
+
runtime: 'any',
|
|
170
|
+
|
|
171
|
+
/** Set to true for testing — throws instead of creating a new database */
|
|
172
|
+
forceReuse: false,
|
|
173
|
+
|
|
174
|
+
/** How long to wait for the advisory lock (ms). Default 60s. */
|
|
175
|
+
lockTimeoutMs: 60_000,
|
|
176
|
+
|
|
177
|
+
async createAdapter(_devUrl: string, _context: AdapterPluginContext): Promise<DatabaseAdapter> {
|
|
178
|
+
const { directUrl } = await getOrCreateDatabase(plugin.forceReuse)
|
|
179
|
+
|
|
180
|
+
const maskedUrl = new URL(directUrl)
|
|
181
|
+
maskedUrl.password = '***'
|
|
182
|
+
log(`connecting to ${maskedUrl.href}`)
|
|
183
|
+
const client = new Client(directUrl)
|
|
184
|
+
await client.connect()
|
|
185
|
+
|
|
186
|
+
const adapter: DatabaseAdapter = {
|
|
187
|
+
async query(sql: string, args?: unknown[]): Promise<QueryResult> {
|
|
188
|
+
const result = await client.query({ text: sql, values: args, rowMode: 'array' })
|
|
189
|
+
return {
|
|
190
|
+
columns: result.fields.map((f) => f.name),
|
|
191
|
+
rows: (result.rows as unknown[][]).map((row) => row.map((v) => normalizeValue(v))),
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
async exec(sql: string): Promise<ExecResult> {
|
|
195
|
+
const result = await client.query(sql)
|
|
196
|
+
return { rowsAffected: result.rowCount ?? 0 }
|
|
197
|
+
},
|
|
198
|
+
async close(): Promise<void> {
|
|
199
|
+
log('releasing lock...')
|
|
200
|
+
try {
|
|
201
|
+
await client.query(`SELECT pg_advisory_unlock(${LOCK_KEY})`)
|
|
202
|
+
} catch {
|
|
203
|
+
// Connection may already be closed — lock releases with session
|
|
204
|
+
}
|
|
205
|
+
await client.end()
|
|
206
|
+
log('closed')
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
log('acquiring advisory lock...')
|
|
212
|
+
await adapter.exec(`SET lock_timeout = '${plugin.lockTimeoutMs}ms'`)
|
|
213
|
+
await adapter.exec(`SELECT pg_advisory_lock(${LOCK_KEY})`)
|
|
214
|
+
log('lock acquired, wiping schemas...')
|
|
215
|
+
|
|
216
|
+
// Drop ALL user schemas — previous runs may have created extras (e.g. kitchen-sink creates a, b, c, d)
|
|
217
|
+
const schemas = await adapter.query(
|
|
218
|
+
"SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema', 'pg_catalog', 'pg_toast')",
|
|
219
|
+
)
|
|
220
|
+
for (const row of schemas.rows) {
|
|
221
|
+
const name = (row[0] as string).replace(/"/g, '""')
|
|
222
|
+
await adapter.exec(`DROP SCHEMA "${name}" CASCADE`)
|
|
223
|
+
}
|
|
224
|
+
await adapter.exec('CREATE SCHEMA public')
|
|
225
|
+
log('ready')
|
|
226
|
+
} catch (err) {
|
|
227
|
+
await adapter.close()
|
|
228
|
+
throw err
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return adapter
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export default plugin
|