@sqldoc/db 0.0.7 → 0.0.8
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 +2 -4
- package/src/db/docker.ts +145 -0
- package/src/db/mysql-docker.ts +11 -20
- package/src/db/postgres-docker.ts +23 -57
- package/wasm/atlas.wasm +0 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sqldoc/db",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.8",
|
|
5
5
|
"description": "Atlas WASI integration for sqldoc -- schema types, database adapters, WASI runner",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -26,11 +26,9 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@electric-sql/pglite": "^0.4.0",
|
|
29
|
-
"@testcontainers/postgresql": "^11.13.0",
|
|
30
29
|
"mysql2": "^3.20.0",
|
|
31
30
|
"pg": "^8.13.0",
|
|
32
|
-
"picocolors": "1.1.1"
|
|
33
|
-
"testcontainers": "^11.13.0"
|
|
31
|
+
"picocolors": "1.1.1"
|
|
34
32
|
},
|
|
35
33
|
"devDependencies": {
|
|
36
34
|
"@types/pg": "^8.11.0",
|
package/src/db/docker.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker container management for ephemeral dev databases.
|
|
3
|
+
* Uses Docker CLI directly — no testcontainers dependency.
|
|
4
|
+
*
|
|
5
|
+
* Containers are labeled `sqldoc-dev` and auto-removed after 60s via --stop-timeout.
|
|
6
|
+
* On startup, stale containers (>1 min old) are cleaned up.
|
|
7
|
+
*/
|
|
8
|
+
import { execSync, spawnSync } from 'node:child_process'
|
|
9
|
+
import * as path from 'node:path'
|
|
10
|
+
|
|
11
|
+
const LABEL = 'sqldoc-dev'
|
|
12
|
+
|
|
13
|
+
/** Clean up any sqldoc-dev containers older than 1 minute */
|
|
14
|
+
export function cleanupStaleContainers(): void {
|
|
15
|
+
try {
|
|
16
|
+
const result = execSync(
|
|
17
|
+
`docker ps -aq --filter label=${LABEL} --filter status=running --format "{{.ID}} {{.CreatedAt}}"`,
|
|
18
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
|
|
19
|
+
)
|
|
20
|
+
for (const line of result.trim().split('\n').filter(Boolean)) {
|
|
21
|
+
const [id, ...dateParts] = line.split(' ')
|
|
22
|
+
const created = new Date(dateParts.join(' '))
|
|
23
|
+
if (Date.now() - created.getTime() > 60_000) {
|
|
24
|
+
spawnSync('docker', ['rm', '-f', id], { stdio: 'pipe' })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// Docker not available or no containers — fine
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DockerContainer {
|
|
33
|
+
id: string
|
|
34
|
+
host: string
|
|
35
|
+
port: number
|
|
36
|
+
stop(): void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Start a Docker container with the given image and port mapping.
|
|
41
|
+
* Returns when the container is running and the port is mapped.
|
|
42
|
+
*/
|
|
43
|
+
export function startContainer(opts: {
|
|
44
|
+
image: string
|
|
45
|
+
env: Record<string, string>
|
|
46
|
+
port: number
|
|
47
|
+
readyLog: string | RegExp
|
|
48
|
+
}): DockerContainer {
|
|
49
|
+
cleanupStaleContainers()
|
|
50
|
+
|
|
51
|
+
const envArgs = Object.entries(opts.env).flatMap(([k, v]) => ['-e', `${k}=${v}`])
|
|
52
|
+
const name = `sqldoc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
|
53
|
+
|
|
54
|
+
const result = spawnSync(
|
|
55
|
+
'docker',
|
|
56
|
+
['run', '-d', '--rm', '--name', name, '--label', LABEL, '-p', `0:${opts.port}`, ...envArgs, opts.image],
|
|
57
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if (result.status !== 0) {
|
|
61
|
+
throw new Error(`Failed to start Docker container: ${result.stderr}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const id = result.stdout.trim().slice(0, 12)
|
|
65
|
+
|
|
66
|
+
// Get mapped port
|
|
67
|
+
const portOutput = execSync(`docker port ${id} ${opts.port}`, { encoding: 'utf-8' }).trim()
|
|
68
|
+
// Format: 0.0.0.0:55432 or [::]:55432
|
|
69
|
+
const portMatch = portOutput.match(/:(\d+)$/)
|
|
70
|
+
if (!portMatch) {
|
|
71
|
+
spawnSync('docker', ['rm', '-f', id], { stdio: 'pipe' })
|
|
72
|
+
throw new Error(`Failed to get mapped port for container ${id}: ${portOutput}`)
|
|
73
|
+
}
|
|
74
|
+
const port = parseInt(portMatch[1], 10)
|
|
75
|
+
|
|
76
|
+
// Wait for ready log message
|
|
77
|
+
waitForLog(id, opts.readyLog, 30_000)
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
id,
|
|
81
|
+
host: '127.0.0.1',
|
|
82
|
+
port,
|
|
83
|
+
stop() {
|
|
84
|
+
spawnSync('docker', ['rm', '-f', id], { stdio: 'pipe' })
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build a Docker image from a Dockerfile and start a container.
|
|
91
|
+
*/
|
|
92
|
+
export function startContainerFromDockerfile(opts: {
|
|
93
|
+
dockerfilePath: string
|
|
94
|
+
env: Record<string, string>
|
|
95
|
+
port: number
|
|
96
|
+
readyLog: string | RegExp
|
|
97
|
+
}): DockerContainer {
|
|
98
|
+
const absPath = path.resolve(opts.dockerfilePath)
|
|
99
|
+
const dir = path.dirname(absPath)
|
|
100
|
+
const tag = `sqldoc-dev-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
101
|
+
|
|
102
|
+
const buildResult = spawnSync('docker', ['build', '-t', tag, '-f', absPath, dir], {
|
|
103
|
+
encoding: 'utf-8',
|
|
104
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
105
|
+
timeout: 60_000,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
if (buildResult.status !== 0 && buildResult.status !== null) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Failed to build Docker image (exit ${buildResult.status}): ${buildResult.stderr || buildResult.stdout}`,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
if (buildResult.error) {
|
|
114
|
+
throw new Error(`Failed to build Docker image: ${buildResult.error.message}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const container = startContainer({ ...opts, image: tag })
|
|
118
|
+
|
|
119
|
+
// Override stop to also remove the built image
|
|
120
|
+
const originalStop = container.stop
|
|
121
|
+
container.stop = () => {
|
|
122
|
+
originalStop()
|
|
123
|
+
spawnSync('docker', ['rmi', tag], { stdio: 'pipe' })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return container
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Poll docker logs until the ready message appears or timeout */
|
|
130
|
+
function waitForLog(containerId: string, pattern: string | RegExp, timeoutMs: number): void {
|
|
131
|
+
const start = Date.now()
|
|
132
|
+
while (Date.now() - start < timeoutMs) {
|
|
133
|
+
try {
|
|
134
|
+
const logs = execSync(`docker logs ${containerId} 2>&1`, { encoding: 'utf-8' })
|
|
135
|
+
if (typeof pattern === 'string' ? logs.includes(pattern) : pattern.test(logs)) {
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// Container might not be ready yet
|
|
140
|
+
}
|
|
141
|
+
spawnSync('sleep', ['0.5'])
|
|
142
|
+
}
|
|
143
|
+
spawnSync('docker', ['rm', '-f', containerId], { stdio: 'pipe' })
|
|
144
|
+
throw new Error(`Container ${containerId} did not become ready within ${timeoutMs}ms`)
|
|
145
|
+
}
|
package/src/db/mysql-docker.ts
CHANGED
|
@@ -1,31 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
* MySQL Docker adapter using testcontainers GenericContainer.
|
|
3
|
-
* Uses log-based wait strategy (same as Postgres adapter) for Bun compatibility —
|
|
4
|
-
* MySqlContainer's built-in health check hangs in Bun's runtime.
|
|
5
|
-
*/
|
|
6
|
-
import { GenericContainer, Wait } from 'testcontainers'
|
|
1
|
+
import { startContainer } from './docker.ts'
|
|
7
2
|
import { createMysqlAdapter } from './mysql.ts'
|
|
8
3
|
import type { DatabaseAdapter } from './types.ts'
|
|
9
4
|
|
|
10
5
|
/**
|
|
11
|
-
* Create a Docker-based MySQL DatabaseAdapter
|
|
6
|
+
* Create a Docker-based MySQL DatabaseAdapter.
|
|
12
7
|
* Supports: docker://mysql:8, docker://mysql:8.0, docker://mariadb:10, etc.
|
|
13
8
|
*/
|
|
14
9
|
export async function createMysqlDockerAdapter(devUrl: string): Promise<DatabaseAdapter> {
|
|
15
10
|
const imageName = devUrl.slice('docker://'.length)
|
|
16
11
|
|
|
17
|
-
const container =
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.withWaitStrategy(Wait.forLogMessage(/ready for connections.*port: 3306/, 2))
|
|
24
|
-
.start()
|
|
12
|
+
const container = startContainer({
|
|
13
|
+
image: imageName,
|
|
14
|
+
env: { MYSQL_ROOT_PASSWORD: 'sqldoc', MYSQL_DATABASE: 'sqldoc_dev' },
|
|
15
|
+
port: 3306,
|
|
16
|
+
readyLog: /ready for connections.*port: 3306/,
|
|
17
|
+
})
|
|
25
18
|
|
|
26
|
-
const
|
|
27
|
-
const port = container.getMappedPort(3306)
|
|
28
|
-
const connectionUri = `mysql://root:sqldoc@${host}:${port}/sqldoc_dev`
|
|
19
|
+
const connectionUri = `mysql://root:sqldoc@${container.host}:${container.port}/sqldoc_dev`
|
|
29
20
|
|
|
30
21
|
// Retry connection — container may need a moment after port is mapped
|
|
31
22
|
let mysqlAdapter: DatabaseAdapter | undefined
|
|
@@ -39,7 +30,7 @@ export async function createMysqlDockerAdapter(devUrl: string): Promise<Database
|
|
|
39
30
|
}
|
|
40
31
|
|
|
41
32
|
if (!mysqlAdapter) {
|
|
42
|
-
|
|
33
|
+
container.stop()
|
|
43
34
|
throw new Error(`Failed to connect to Docker MySQL at ${connectionUri}`)
|
|
44
35
|
}
|
|
45
36
|
|
|
@@ -48,7 +39,7 @@ export async function createMysqlDockerAdapter(devUrl: string): Promise<Database
|
|
|
48
39
|
exec: mysqlAdapter.exec,
|
|
49
40
|
async close() {
|
|
50
41
|
await mysqlAdapter.close()
|
|
51
|
-
|
|
42
|
+
container.stop()
|
|
52
43
|
},
|
|
53
44
|
}
|
|
54
45
|
}
|
|
@@ -1,68 +1,34 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { PostgreSqlContainer } from '@testcontainers/postgresql'
|
|
3
|
-
import { GenericContainer, Wait } from 'testcontainers'
|
|
1
|
+
import { startContainer, startContainerFromDockerfile } from './docker.ts'
|
|
4
2
|
import { createPostgresAdapter } from './postgres.ts'
|
|
5
3
|
import type { DatabaseAdapter } from './types.ts'
|
|
6
4
|
|
|
7
5
|
/**
|
|
8
|
-
* Create a Docker-based DatabaseAdapter
|
|
9
|
-
* Spins up an ephemeral
|
|
10
|
-
* automatically cleans up on close() (testcontainers + Ryuk handle
|
|
11
|
-
* orphan cleanup even on process crash).
|
|
6
|
+
* Create a Docker-based DatabaseAdapter for Postgres.
|
|
7
|
+
* Spins up an ephemeral container, connects via pg, cleans up on close().
|
|
12
8
|
*
|
|
13
9
|
* Supports:
|
|
14
10
|
* docker://postgres:16 — official or custom image
|
|
15
11
|
* dockerfile://path/to/file — build from Dockerfile
|
|
16
12
|
*/
|
|
17
13
|
export async function createPostgresDockerAdapter(devUrl: string): Promise<DatabaseAdapter> {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
exec: pgAdapter.exec,
|
|
37
|
-
async close() {
|
|
38
|
-
await pgAdapter.close()
|
|
39
|
-
await container.stop()
|
|
40
|
-
},
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function createFromDockerfile(dockerfilePath: string): Promise<DatabaseAdapter> {
|
|
45
|
-
const absPath = path.resolve(dockerfilePath)
|
|
46
|
-
const dir = path.dirname(absPath)
|
|
47
|
-
const file = path.basename(absPath)
|
|
48
|
-
|
|
49
|
-
const image = await GenericContainer.fromDockerfile(dir, file).build()
|
|
50
|
-
|
|
51
|
-
const container = await image
|
|
52
|
-
.withEnvironment({
|
|
53
|
-
POSTGRES_DB: 'sqldoc_dev',
|
|
54
|
-
POSTGRES_USER: 'sqldoc',
|
|
55
|
-
POSTGRES_PASSWORD: 'sqldoc',
|
|
56
|
-
})
|
|
57
|
-
.withExposedPorts(5432)
|
|
58
|
-
.withWaitStrategy(Wait.forLogMessage('database system is ready to accept connections', 2))
|
|
59
|
-
.withStartupTimeout(30_000)
|
|
60
|
-
.start()
|
|
61
|
-
|
|
62
|
-
// Wait for postgres to accept connections
|
|
63
|
-
const host = container.getHost()
|
|
64
|
-
const port = container.getMappedPort(5432)
|
|
65
|
-
const connectionUri = `postgres://sqldoc:sqldoc@${host}:${port}/sqldoc_dev`
|
|
14
|
+
const isDockerfile = devUrl.startsWith('dockerfile://')
|
|
15
|
+
const readyLog = 'database system is ready to accept connections'
|
|
16
|
+
|
|
17
|
+
const container = isDockerfile
|
|
18
|
+
? startContainerFromDockerfile({
|
|
19
|
+
dockerfilePath: devUrl.slice('dockerfile://'.length),
|
|
20
|
+
env: { POSTGRES_DB: 'sqldoc_dev', POSTGRES_USER: 'sqldoc', POSTGRES_PASSWORD: 'sqldoc' },
|
|
21
|
+
port: 5432,
|
|
22
|
+
readyLog,
|
|
23
|
+
})
|
|
24
|
+
: startContainer({
|
|
25
|
+
image: devUrl.slice('docker://'.length),
|
|
26
|
+
env: { POSTGRES_DB: 'sqldoc_dev', POSTGRES_USER: 'sqldoc', POSTGRES_PASSWORD: 'sqldoc' },
|
|
27
|
+
port: 5432,
|
|
28
|
+
readyLog,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const connectionUri = `postgres://sqldoc:sqldoc@${container.host}:${container.port}/sqldoc_dev`
|
|
66
32
|
|
|
67
33
|
// Retry connection — container may need a moment after port is mapped
|
|
68
34
|
let pgAdapter: DatabaseAdapter | undefined
|
|
@@ -76,7 +42,7 @@ async function createFromDockerfile(dockerfilePath: string): Promise<DatabaseAda
|
|
|
76
42
|
}
|
|
77
43
|
|
|
78
44
|
if (!pgAdapter) {
|
|
79
|
-
|
|
45
|
+
container.stop()
|
|
80
46
|
throw new Error(`Failed to connect to Docker Postgres at ${connectionUri}`)
|
|
81
47
|
}
|
|
82
48
|
|
|
@@ -85,7 +51,7 @@ async function createFromDockerfile(dockerfilePath: string): Promise<DatabaseAda
|
|
|
85
51
|
exec: pgAdapter.exec,
|
|
86
52
|
async close() {
|
|
87
53
|
await pgAdapter.close()
|
|
88
|
-
|
|
54
|
+
container.stop()
|
|
89
55
|
},
|
|
90
56
|
}
|
|
91
57
|
}
|
package/wasm/atlas.wasm
CHANGED
|
Binary file
|