@sqldoc/atlas 0.0.1
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 +37 -0
- package/src/__tests__/bridge.test.ts +210 -0
- package/src/__tests__/docker.test.ts +87 -0
- package/src/__tests__/extensions.test.ts +148 -0
- package/src/__tests__/pglite.test.ts +57 -0
- package/src/__tests__/runner.test.ts +151 -0
- package/src/__tests__/types.test.ts +77 -0
- package/src/__tests__/wasi-compat.test.ts +41 -0
- package/src/bridge.ts +152 -0
- package/src/db/docker.ts +91 -0
- package/src/db/pglite.ts +77 -0
- package/src/db/postgres.ts +44 -0
- package/src/db/types.ts +26 -0
- package/src/extensions.ts +116 -0
- package/src/index.ts +97 -0
- package/src/runner.ts +263 -0
- package/src/types.ts +305 -0
- package/src/wasi-host.ts +175 -0
- package/src/worker.ts +106 -0
- package/wasm/atlas.wasm +0 -0
package/src/wasi-host.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WASI module instantiation with custom atlas_sql host function import.
|
|
3
|
+
*
|
|
4
|
+
* Includes workarounds for Bun's WASI bugs:
|
|
5
|
+
* 1. FD_MAP ignores stdin/stdout/stderr constructor options
|
|
6
|
+
* 2. random_get returns byte count instead of errno 0
|
|
7
|
+
* 3. proc_exit calls process.exit() instead of throwing
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as crypto from 'node:crypto'
|
|
11
|
+
import * as fs from 'node:fs'
|
|
12
|
+
import * as os from 'node:os'
|
|
13
|
+
import * as path from 'node:path'
|
|
14
|
+
import { WASI } from 'node:wasi'
|
|
15
|
+
|
|
16
|
+
const isBun = typeof (globalThis as any).Bun !== 'undefined'
|
|
17
|
+
|
|
18
|
+
/** Sentinel thrown by Bun proc_exit workaround */
|
|
19
|
+
class WASIExitError {
|
|
20
|
+
code: number
|
|
21
|
+
constructor(code: number) {
|
|
22
|
+
this.code = code
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get WASI imports compatible with both Node.js and Bun.
|
|
28
|
+
*/
|
|
29
|
+
export function getWasiImports(wasi: WASI): Record<string, any> {
|
|
30
|
+
if (typeof (wasi as any).getImportObject === 'function') {
|
|
31
|
+
return (wasi as any).getImportObject()
|
|
32
|
+
}
|
|
33
|
+
if ((wasi as any).wasiImport) {
|
|
34
|
+
return { wasi_snapshot_preview1: (wasi as any).wasiImport }
|
|
35
|
+
}
|
|
36
|
+
throw new Error('Cannot get WASI imports: neither getImportObject() nor wasiImport available')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WasiRunOptions {
|
|
40
|
+
stdinData: string
|
|
41
|
+
wasmPath: string
|
|
42
|
+
compiledModule?: WebAssembly.Module
|
|
43
|
+
atlasSqlFn: (reqPtr: number, reqLen: number, respPtr: number, respCap: number) => bigint | number
|
|
44
|
+
onInstance?: (instance: WebAssembly.Instance) => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WasiRunResult {
|
|
48
|
+
stdout: string
|
|
49
|
+
module: WebAssembly.Module
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runWasi(options: WasiRunOptions): Promise<WasiRunResult> {
|
|
53
|
+
const { stdinData, wasmPath, atlasSqlFn, onInstance } = options
|
|
54
|
+
const uid = crypto.randomUUID()
|
|
55
|
+
const tmpDir = os.tmpdir()
|
|
56
|
+
const stdinPath = path.join(tmpDir, `atlas-stdin-${uid}.json`)
|
|
57
|
+
const stdoutPath = path.join(tmpDir, `atlas-stdout-${uid}.json`)
|
|
58
|
+
|
|
59
|
+
let stdinFd: number | undefined
|
|
60
|
+
let stdoutFd: number | undefined
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
fs.writeFileSync(stdinPath, stdinData)
|
|
64
|
+
fs.writeFileSync(stdoutPath, '')
|
|
65
|
+
|
|
66
|
+
stdinFd = fs.openSync(stdinPath, 'r')
|
|
67
|
+
stdoutFd = fs.openSync(stdoutPath, 'w')
|
|
68
|
+
|
|
69
|
+
const wasi = new WASI({
|
|
70
|
+
version: 'preview1',
|
|
71
|
+
stdin: stdinFd,
|
|
72
|
+
stdout: stdoutFd,
|
|
73
|
+
returnOnExit: true,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Bun bug #1: FD_MAP ignores constructor options
|
|
77
|
+
if (isBun) {
|
|
78
|
+
const fdMap: Map<number, any> = (wasi as any).FD_MAP
|
|
79
|
+
if (fdMap) {
|
|
80
|
+
const fd0 = fdMap.get(0)
|
|
81
|
+
if (fd0) fd0.real = stdinFd
|
|
82
|
+
const fd1 = fdMap.get(1)
|
|
83
|
+
if (fd1) fd1.real = stdoutFd
|
|
84
|
+
const fd2 = fdMap.get(2)
|
|
85
|
+
if (fd2) fd2.real = 2
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Compile module (or reuse cached)
|
|
90
|
+
let wasmModule = options.compiledModule
|
|
91
|
+
if (!wasmModule) {
|
|
92
|
+
const wasmBytes = fs.readFileSync(wasmPath)
|
|
93
|
+
wasmModule = await WebAssembly.compile(wasmBytes)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Build imports
|
|
97
|
+
const wasiImports = getWasiImports(wasi)
|
|
98
|
+
let instanceRef: WebAssembly.Instance | undefined
|
|
99
|
+
|
|
100
|
+
if (isBun && wasiImports.wasi_snapshot_preview1) {
|
|
101
|
+
// Bun bug #2: random_get returns byte count instead of errno 0
|
|
102
|
+
wasiImports.wasi_snapshot_preview1.random_get = (bufPtr: number, bufLen: number) => {
|
|
103
|
+
if (instanceRef) {
|
|
104
|
+
const mem = instanceRef.exports.memory as WebAssembly.Memory
|
|
105
|
+
const view = new Uint8Array(mem.buffer, bufPtr, bufLen)
|
|
106
|
+
crypto.getRandomValues(view)
|
|
107
|
+
}
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Bun bug #3: proc_exit calls process.exit()
|
|
112
|
+
wasiImports.wasi_snapshot_preview1.proc_exit = (code: number) => {
|
|
113
|
+
throw new WASIExitError(code)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const imports = {
|
|
118
|
+
...wasiImports,
|
|
119
|
+
env: { atlas_sql: atlasSqlFn },
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Instantiate
|
|
123
|
+
const instance = await WebAssembly.instantiate(wasmModule, imports)
|
|
124
|
+
instanceRef = instance
|
|
125
|
+
|
|
126
|
+
if (isBun && typeof (wasi as any).setMemory === 'function') {
|
|
127
|
+
;(wasi as any).setMemory(instance.exports.memory)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (onInstance) {
|
|
131
|
+
onInstance(instance)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Run
|
|
135
|
+
try {
|
|
136
|
+
if (isBun) {
|
|
137
|
+
;(instance.exports._start as Function)()
|
|
138
|
+
} else {
|
|
139
|
+
wasi.start(instance)
|
|
140
|
+
}
|
|
141
|
+
} catch (err: unknown) {
|
|
142
|
+
if (err instanceof WASIExitError) {
|
|
143
|
+
// Bun sentinel — non-zero exit is fine, we read stdout for error JSON
|
|
144
|
+
} else {
|
|
145
|
+
// Node: returnOnExit throws on non-zero exit code
|
|
146
|
+
const isExitError = err instanceof Error && ('code' in err || err.message.includes('exit'))
|
|
147
|
+
if (!isExitError) throw err
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Close fds before reading stdout
|
|
152
|
+
fs.closeSync(stdinFd)
|
|
153
|
+
stdinFd = undefined
|
|
154
|
+
fs.closeSync(stdoutFd)
|
|
155
|
+
stdoutFd = undefined
|
|
156
|
+
|
|
157
|
+
const stdout = fs.readFileSync(stdoutPath, 'utf-8')
|
|
158
|
+
return { stdout, module: wasmModule }
|
|
159
|
+
} finally {
|
|
160
|
+
if (stdinFd !== undefined)
|
|
161
|
+
try {
|
|
162
|
+
fs.closeSync(stdinFd)
|
|
163
|
+
} catch {}
|
|
164
|
+
if (stdoutFd !== undefined)
|
|
165
|
+
try {
|
|
166
|
+
fs.closeSync(stdoutFd)
|
|
167
|
+
} catch {}
|
|
168
|
+
try {
|
|
169
|
+
fs.unlinkSync(stdinPath)
|
|
170
|
+
} catch {}
|
|
171
|
+
try {
|
|
172
|
+
fs.unlinkSync(stdoutPath)
|
|
173
|
+
} catch {}
|
|
174
|
+
}
|
|
175
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker thread entry point for Atlas WASI execution.
|
|
3
|
+
*
|
|
4
|
+
* This file runs inside a node:worker_threads Worker. It:
|
|
5
|
+
* 1. Receives initialization data via workerData
|
|
6
|
+
* 2. Creates the atlas_sql callback using SharedArrayBuffer + Atomics
|
|
7
|
+
* 3. Runs the WASI module with the stdin data
|
|
8
|
+
* 4. Posts the stdout result back to the parent
|
|
9
|
+
*
|
|
10
|
+
* The atlas_sql callback bridges synchronous WASM calls to the main thread's
|
|
11
|
+
* async database adapter using Atomics.wait (blocking on the worker thread).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { parentPort, workerData } from 'node:worker_threads'
|
|
15
|
+
import { type BridgeBuffers, bridgeRequest, SIGNAL_DONE } from './bridge.ts'
|
|
16
|
+
import { runWasi } from './wasi-host.ts'
|
|
17
|
+
|
|
18
|
+
interface WorkerInit {
|
|
19
|
+
wasmPath: string
|
|
20
|
+
controlBuffer: SharedArrayBuffer
|
|
21
|
+
dataBuffer: SharedArrayBuffer
|
|
22
|
+
stdinData: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { wasmPath, controlBuffer, dataBuffer, stdinData } = workerData as WorkerInit
|
|
26
|
+
|
|
27
|
+
const buffers: BridgeBuffers = {
|
|
28
|
+
control: controlBuffer,
|
|
29
|
+
data: dataBuffer,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// WASM instance memory -- set via onInstance before wasi.start()
|
|
33
|
+
let wasmMemory: WebAssembly.Memory | null = null
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The atlas_sql host function that bridges sync WASM calls
|
|
37
|
+
* to the main thread via SharedArrayBuffer + Atomics.
|
|
38
|
+
*
|
|
39
|
+
* Signature matches Go's //go:wasmimport env atlas_sql:
|
|
40
|
+
* (reqPtr, reqLen, respPtr, respCap) => int64
|
|
41
|
+
*
|
|
42
|
+
* Negative return = buffer too small (abs value = needed size).
|
|
43
|
+
* Positive return = actual response length written.
|
|
44
|
+
*/
|
|
45
|
+
function atlasSql(reqPtr: number, reqLen: number, respPtr: number, respCap: number): bigint {
|
|
46
|
+
if (!wasmMemory) {
|
|
47
|
+
throw new Error('WASM memory not yet available in atlas_sql callback')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 1. Read request JSON from WASM memory
|
|
51
|
+
const reqBytes = new Uint8Array(wasmMemory.buffer, reqPtr, reqLen)
|
|
52
|
+
const requestJson = new TextDecoder().decode(reqBytes.slice())
|
|
53
|
+
|
|
54
|
+
// 2. Send request to main thread via bridge and block until response
|
|
55
|
+
const responseJson = bridgeRequest(buffers, requestJson)
|
|
56
|
+
|
|
57
|
+
// 3. Encode response and check capacity
|
|
58
|
+
const respEncoded = new TextEncoder().encode(responseJson)
|
|
59
|
+
|
|
60
|
+
if (respEncoded.byteLength > respCap) {
|
|
61
|
+
return BigInt(-respEncoded.byteLength)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 4. Copy response into WASM memory
|
|
65
|
+
const wasmResp = new Uint8Array(wasmMemory.buffer, respPtr, respCap)
|
|
66
|
+
wasmResp.set(respEncoded)
|
|
67
|
+
|
|
68
|
+
return BigInt(respEncoded.byteLength)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function main(): Promise<void> {
|
|
72
|
+
if (!parentPort) {
|
|
73
|
+
throw new Error('worker.ts must run inside a worker thread')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const result = await runWasi({
|
|
78
|
+
stdinData,
|
|
79
|
+
wasmPath,
|
|
80
|
+
atlasSqlFn: atlasSql,
|
|
81
|
+
onInstance(instance) {
|
|
82
|
+
// Capture WASM memory before execution starts.
|
|
83
|
+
// atlas_sql will use this to read requests from / write responses to WASM memory.
|
|
84
|
+
wasmMemory = instance.exports.memory as WebAssembly.Memory
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Signal main thread that WASI execution is done
|
|
89
|
+
const control = new Int32Array(buffers.control)
|
|
90
|
+
Atomics.store(control, 0, SIGNAL_DONE)
|
|
91
|
+
Atomics.notify(control, 0)
|
|
92
|
+
|
|
93
|
+
// Post result back
|
|
94
|
+
parentPort.postMessage({ type: 'result', stdout: result.stdout })
|
|
95
|
+
} catch (err: unknown) {
|
|
96
|
+
// Signal done even on error
|
|
97
|
+
const control = new Int32Array(buffers.control)
|
|
98
|
+
Atomics.store(control, 0, SIGNAL_DONE)
|
|
99
|
+
Atomics.notify(control, 0)
|
|
100
|
+
|
|
101
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
102
|
+
parentPort.postMessage({ type: 'error', error: message })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
main()
|
package/wasm/atlas.wasm
ADDED
|
Binary file
|