@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 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