@silkline/hasura-sim 0.1.0
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/README.md +65 -0
- package/package.json +37 -0
- package/src/index.mjs +331 -0
- package/src/wire.mjs +230 -0
- package/wasm/reactor.js +119 -0
- package/wasm/reactor.wasm +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @silkline/hasura-sim
|
|
2
|
+
|
|
3
|
+
An in-memory Hasura GraphQL engine for tests. The real Hasura GraphQL Engine v2
|
|
4
|
+
(Haskell) is compiled to WebAssembly and paired with [pglite](https://pglite.dev)
|
|
5
|
+
(WASM Postgres). Feed it static Hasura metadata and a migrated pglite instance,
|
|
6
|
+
then run GraphQL operations and get back exactly what the real engine returns —
|
|
7
|
+
no Docker, no Postgres server, no network.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
GraphQL op ─▶ sim_execute (wasm Hasura engine)
|
|
13
|
+
│ parse → IR → SQL (real engine: permissions,
|
|
14
|
+
│ session vars, relationships, …)
|
|
15
|
+
▼
|
|
16
|
+
pg_exec bridge ──▶ pglite (in-process Postgres)
|
|
17
|
+
▲ │
|
|
18
|
+
└──── rows ──────┘
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The engine runs the real schema-cache builder and query/mutation translator.
|
|
22
|
+
SQL is executed against pglite over the PostgreSQL wire protocol (`src/wire.mjs`),
|
|
23
|
+
so binary params + OIDs and result formats round-trip faithfully.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
29
|
+
import { createSimulator } from "@silkline/hasura-sim";
|
|
30
|
+
|
|
31
|
+
const db = new PGlite();
|
|
32
|
+
await db.exec(/* your migrations */);
|
|
33
|
+
|
|
34
|
+
const sim = await createSimulator({
|
|
35
|
+
db,
|
|
36
|
+
metadataJson: {
|
|
37
|
+
version: 3,
|
|
38
|
+
sources: [
|
|
39
|
+
{
|
|
40
|
+
name: "default",
|
|
41
|
+
kind: "postgres",
|
|
42
|
+
configuration: {
|
|
43
|
+
connection_info: { database_url: "postgres://localhost/db" },
|
|
44
|
+
},
|
|
45
|
+
tables: [{ table: { schema: "public", name: "widget" } }],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const { data, errors } = await sim.execute({
|
|
52
|
+
query: "query ($id: Int!) { widget(where: { id: { _eq: $id } }) { id name } }",
|
|
53
|
+
variables: { id: 2 },
|
|
54
|
+
role: "user", // default "admin"
|
|
55
|
+
sessionVariables: { "x-hasura-user-id": "42" },
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`metadataJson` is consolidated Hasura metadata (`hasura metadata export -o json`).
|
|
60
|
+
The `database_url` is ignored — all SQL is routed to the provided pglite `db`.
|
|
61
|
+
|
|
62
|
+
## Status
|
|
63
|
+
|
|
64
|
+
Validated end-to-end: queries (param-free + parameterized) and mutations
|
|
65
|
+
(insert, committing). Remote schemas and actions are out of scope (stubbed).
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@silkline/hasura-sim",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "In-memory Hasura GraphQL Engine (compiled to WebAssembly) + pglite — runs real GraphQL queries/mutations offline for tests and codegen, with no Docker/Postgres/network.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"wasm/reactor.wasm",
|
|
13
|
+
"wasm/reactor.js",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build:wasm": "./scripts/build-wasm.sh"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"graphql": "^16.14.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@electric-sql/pglite": ">=0.2.0"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/Silkline/hasura-wasm.git",
|
|
34
|
+
"directory": "packages/hasura-sim"
|
|
35
|
+
},
|
|
36
|
+
"license": "Apache-2.0"
|
|
37
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// @silkline/hasura-sim — in-memory Hasura GraphQL engine (compiled to wasm)
|
|
2
|
+
// paired with pglite. Feed it static Hasura metadata + a migrated pglite
|
|
3
|
+
// instance, then run GraphQL operations and get back exactly what the real
|
|
4
|
+
// engine would return, with no Docker/Postgres/network.
|
|
5
|
+
|
|
6
|
+
import { WASI } from "node:wasi";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
11
|
+
import {
|
|
12
|
+
buildClientSchema,
|
|
13
|
+
buildSchema,
|
|
14
|
+
getIntrospectionQuery,
|
|
15
|
+
graphql,
|
|
16
|
+
introspectionFromSchema,
|
|
17
|
+
printSchema,
|
|
18
|
+
} from "graphql";
|
|
19
|
+
|
|
20
|
+
// Build an executable schema from SDL + resolvers using only graphql-js (no
|
|
21
|
+
// @graphql-tools) so there is a single `graphql` instance — mixing two copies
|
|
22
|
+
// trips graphql-js's "Duplicate graphql modules" guard.
|
|
23
|
+
function buildExecutableSchema(sdl, resolvers) {
|
|
24
|
+
const schema = buildSchema(sdl);
|
|
25
|
+
for (const [typeName, fieldResolvers] of Object.entries(resolvers ?? {})) {
|
|
26
|
+
const type = schema.getType(typeName);
|
|
27
|
+
if (!type || typeof type.getFields !== "function") continue;
|
|
28
|
+
const fields = type.getFields();
|
|
29
|
+
for (const [fieldName, resolve] of Object.entries(fieldResolvers)) {
|
|
30
|
+
if (fields[fieldName]) fields[fieldName].resolve = resolve;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return schema;
|
|
34
|
+
}
|
|
35
|
+
import {
|
|
36
|
+
buildExtendedQuery,
|
|
37
|
+
buildSimpleQuery,
|
|
38
|
+
decodeResponse,
|
|
39
|
+
} from "./wire.mjs";
|
|
40
|
+
|
|
41
|
+
// Re-exported so consumers can build/seed a pglite instance without taking
|
|
42
|
+
// their own @electric-sql/pglite dependency.
|
|
43
|
+
export { PGlite } from "@electric-sql/pglite";
|
|
44
|
+
|
|
45
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
|
|
47
|
+
// Run one Haskell-bridge request against a pglite connection via the raw wire
|
|
48
|
+
// protocol. Exported for tests; normally reached through the global below.
|
|
49
|
+
export async function execOnPglite(db, requestJson) {
|
|
50
|
+
const req = JSON.parse(requestJson);
|
|
51
|
+
const params = req.params ?? [];
|
|
52
|
+
const rfmt = req.rfmt ?? 0;
|
|
53
|
+
// The extended protocol (Parse/Bind) is the faithful path: it honours the
|
|
54
|
+
// requested result format (rfmt=1 ⇒ binary), which Hasura's decoders rely on
|
|
55
|
+
// — e.g. the mutation permission-check boolean must come back as a binary
|
|
56
|
+
// 0x01/0x00, not text "t"/"f". Its one limitation is that a Parse may carry
|
|
57
|
+
// only a single statement, so Hasura's multi-statement connection setup
|
|
58
|
+
// ("SET a; SET b;") falls back to the simple query protocol, whose text-only
|
|
59
|
+
// results are harmless there (SET returns no data).
|
|
60
|
+
const extended = decodeResponse(
|
|
61
|
+
await db.execProtocolRaw(buildExtendedQuery({ sql: req.sql, params, rfmt })),
|
|
62
|
+
);
|
|
63
|
+
if (isMultiStatementError(extended)) {
|
|
64
|
+
return JSON.stringify(
|
|
65
|
+
decodeResponse(await db.execProtocolRaw(buildSimpleQuery(req.sql))),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return JSON.stringify(extended);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The extended protocol rejects a Parse containing more than one statement
|
|
72
|
+
// (SQLSTATE 42601, "cannot insert multiple commands into a prepared statement").
|
|
73
|
+
function isMultiStatementError(res) {
|
|
74
|
+
return (
|
|
75
|
+
res.status === "FatalError" &&
|
|
76
|
+
res.error != null &&
|
|
77
|
+
/multiple commands/i.test(res.error.message ?? "")
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Normalise a remote-schema definition to the introspection *response* shape
|
|
82
|
+
// the engine expects ({"data":{"__schema":…}}). Accepts:
|
|
83
|
+
// - an SDL string (e.g. the contents of server-schema.gql),
|
|
84
|
+
// - { sdl: "<SDL>" },
|
|
85
|
+
// - { introspection: <{__schema} or {data:{__schema}}> }.
|
|
86
|
+
export function remoteSchemaToIntrospection(def) {
|
|
87
|
+
if (typeof def === "string") {
|
|
88
|
+
return { data: introspectionFromSchema(buildSchema(def)) };
|
|
89
|
+
}
|
|
90
|
+
if (def?.sdl) {
|
|
91
|
+
return { data: introspectionFromSchema(buildSchema(def.sdl)) };
|
|
92
|
+
}
|
|
93
|
+
if (def?.introspection) {
|
|
94
|
+
const i = def.introspection;
|
|
95
|
+
return i.data ? i : { data: i };
|
|
96
|
+
}
|
|
97
|
+
throw new Error(
|
|
98
|
+
"hasura-sim: remote schema definition must be an SDL string, { sdl }, or { introspection }",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// HTTP bridge: the wasm engine issues all remote-schema and action HTTP through
|
|
104
|
+
// a Manager backed by `js_http_exec` (see server/lib/hasura-wasm-sim's
|
|
105
|
+
// HttpBridge). We get the raw HTTP/1.1 request text and return raw response
|
|
106
|
+
// text; dispatch is by the rewritten URL (host marks kind, path carries name).
|
|
107
|
+
|
|
108
|
+
function parseHttpRequest(raw) {
|
|
109
|
+
const sep = raw.indexOf("\r\n\r\n");
|
|
110
|
+
const head = sep === -1 ? raw : raw.slice(0, sep);
|
|
111
|
+
const body = sep === -1 ? "" : raw.slice(sep + 4);
|
|
112
|
+
const lines = head.split("\r\n");
|
|
113
|
+
const [method, path] = (lines[0] ?? "").split(" ");
|
|
114
|
+
const headers = {};
|
|
115
|
+
for (let i = 1; i < lines.length; i++) {
|
|
116
|
+
const idx = lines[i].indexOf(":");
|
|
117
|
+
if (idx > 0) {
|
|
118
|
+
headers[lines[i].slice(0, idx).trim().toLowerCase()] = lines[i]
|
|
119
|
+
.slice(idx + 1)
|
|
120
|
+
.trim();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { method, path: path ?? "/", headers, body };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function httpResponse(status, bodyObj) {
|
|
127
|
+
const body = typeof bodyObj === "string" ? bodyObj : JSON.stringify(bodyObj);
|
|
128
|
+
const len = Buffer.byteLength(body, "utf8");
|
|
129
|
+
const reason =
|
|
130
|
+
{ 200: "OK", 400: "Bad Request", 404: "Not Found", 500: "Server Error" }[
|
|
131
|
+
status
|
|
132
|
+
] ?? "OK";
|
|
133
|
+
return (
|
|
134
|
+
`HTTP/1.1 ${status} ${reason}\r\n` +
|
|
135
|
+
`content-type: application/json\r\n` +
|
|
136
|
+
`content-length: ${len}\r\n` +
|
|
137
|
+
`connection: close\r\n\r\n` +
|
|
138
|
+
body
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Build the `js_http_exec` handler from the registered remote schemas + actions.
|
|
143
|
+
function makeHttpHandler({ remotes, actions }) {
|
|
144
|
+
return async (rawRequest) => {
|
|
145
|
+
let req;
|
|
146
|
+
try {
|
|
147
|
+
req = parseHttpRequest(rawRequest);
|
|
148
|
+
} catch {
|
|
149
|
+
return httpResponse(400, { errors: [{ message: "hasura-sim: malformed request" }] });
|
|
150
|
+
}
|
|
151
|
+
const host = req.headers["host"] ?? "";
|
|
152
|
+
const name = decodeURIComponent(req.path.replace(/^\//, ""));
|
|
153
|
+
try {
|
|
154
|
+
if (host === "remote.local") {
|
|
155
|
+
const entry = remotes[name];
|
|
156
|
+
const gql = req.body ? JSON.parse(req.body) : {};
|
|
157
|
+
if (entry?.handler) return httpResponse(200, await entry.handler(gql));
|
|
158
|
+
if (entry?.schema) {
|
|
159
|
+
const result = await graphql({
|
|
160
|
+
schema: entry.schema,
|
|
161
|
+
source: gql.query,
|
|
162
|
+
variableValues: gql.variables ?? undefined,
|
|
163
|
+
operationName: gql.operationName ?? undefined,
|
|
164
|
+
});
|
|
165
|
+
return httpResponse(200, result);
|
|
166
|
+
}
|
|
167
|
+
return httpResponse(200, {
|
|
168
|
+
errors: [
|
|
169
|
+
{
|
|
170
|
+
message: `hasura-sim: no resolvers/handler registered for remote schema "${name}". Pass remoteSchemas["${name}"].resolvers or .handler.`,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (host === "action.local") {
|
|
176
|
+
const payload = req.body ? JSON.parse(req.body) : {};
|
|
177
|
+
const actionName = name || payload?.action?.name;
|
|
178
|
+
const handler = actions[actionName];
|
|
179
|
+
if (!handler) {
|
|
180
|
+
return httpResponse(400, {
|
|
181
|
+
message: `hasura-sim: no action handler registered for "${actionName}". Pass actions["${actionName}"].`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
// Hasura's action webhook `input` field is the action's *arguments*
|
|
185
|
+
// object (keyed by your action's arg names, e.g. { input: {...} }).
|
|
186
|
+
// Pass it as the handler's first arg so `({ input }) => …` reads
|
|
187
|
+
// naturally; session vars + raw payload come as the second arg.
|
|
188
|
+
const out = await handler(payload.input ?? {}, {
|
|
189
|
+
sessionVariables: payload.session_variables,
|
|
190
|
+
payload,
|
|
191
|
+
});
|
|
192
|
+
return httpResponse(200, out ?? null);
|
|
193
|
+
}
|
|
194
|
+
return httpResponse(404, {
|
|
195
|
+
errors: [{ message: `hasura-sim: unexpected request host "${host}"` }],
|
|
196
|
+
});
|
|
197
|
+
} catch (err) {
|
|
198
|
+
return httpResponse(500, {
|
|
199
|
+
errors: [{ message: `hasura-sim handler error: ${err?.message ?? err}` }],
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function instantiateReactor() {
|
|
206
|
+
const wasmBytes = await readFile(join(here, "..", "wasm", "reactor.wasm"));
|
|
207
|
+
const wasi = new WASI({
|
|
208
|
+
version: "preview1",
|
|
209
|
+
args: ["reactor.wasm"],
|
|
210
|
+
env: {},
|
|
211
|
+
preopens: { "/": "/" },
|
|
212
|
+
});
|
|
213
|
+
const mod = await WebAssembly.compile(wasmBytes);
|
|
214
|
+
const exportsBag = {};
|
|
215
|
+
const glue = (await import(join(here, "..", "wasm", "reactor.js"))).default(
|
|
216
|
+
exportsBag,
|
|
217
|
+
);
|
|
218
|
+
const instance = await WebAssembly.instantiate(mod, {
|
|
219
|
+
wasi_snapshot_preview1: wasi.wasiImport,
|
|
220
|
+
ghc_wasm_jsffi: glue,
|
|
221
|
+
});
|
|
222
|
+
Object.assign(exportsBag, instance.exports);
|
|
223
|
+
wasi.initialize(instance);
|
|
224
|
+
return instance;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Create a simulator.
|
|
229
|
+
*
|
|
230
|
+
* @param {object} opts
|
|
231
|
+
* @param {object|string} [opts.metadataJson] consolidated Hasura metadata; if
|
|
232
|
+
* given, the schema cache is built immediately.
|
|
233
|
+
* @param {PGlite} [opts.db] a pre-migrated pglite instance (else a fresh one).
|
|
234
|
+
* @returns {Promise<{db, execute, init, version}>}
|
|
235
|
+
*/
|
|
236
|
+
export async function createSimulator({
|
|
237
|
+
metadataJson,
|
|
238
|
+
db,
|
|
239
|
+
remoteSchemas = {},
|
|
240
|
+
actions = {},
|
|
241
|
+
} = {}) {
|
|
242
|
+
const pg = db ?? new PGlite();
|
|
243
|
+
await pg.waitReady;
|
|
244
|
+
|
|
245
|
+
// Build executable schemas / handlers for remote-schema *execution* (the
|
|
246
|
+
// static SDL still feeds schema-cache build via remoteSchemaToIntrospection
|
|
247
|
+
// in init()). Each remoteSchemas[name] is an SDL string, or
|
|
248
|
+
// { sdl, resolvers? , handler? }, or { introspection }.
|
|
249
|
+
const remotes = {};
|
|
250
|
+
for (const [name, def] of Object.entries(remoteSchemas)) {
|
|
251
|
+
const entry = {};
|
|
252
|
+
if (def && typeof def === "object" && !Array.isArray(def)) {
|
|
253
|
+
if (typeof def.handler === "function") {
|
|
254
|
+
entry.handler = def.handler;
|
|
255
|
+
} else if (def.resolvers && def.sdl) {
|
|
256
|
+
entry.schema = buildExecutableSchema(def.sdl, def.resolvers);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
remotes[name] = entry;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The wasm bridges call these globals. Registered before sim_init so the
|
|
263
|
+
// introspection / remote / action calls the build + execute issue reach them.
|
|
264
|
+
globalThis.pg_connect = () => 1; // one in-memory database
|
|
265
|
+
globalThis.pg_exec = (_connId, requestJson) => execOnPglite(pg, requestJson);
|
|
266
|
+
globalThis.js_http_exec = makeHttpHandler({ remotes, actions });
|
|
267
|
+
|
|
268
|
+
const instance = await instantiateReactor();
|
|
269
|
+
const { sim_init, sim_execute, sim_version } = instance.exports;
|
|
270
|
+
|
|
271
|
+
const init = async (metadata, remotes = {}) => {
|
|
272
|
+
const metaPayload =
|
|
273
|
+
typeof metadata === "string" ? metadata : JSON.stringify(metadata);
|
|
274
|
+
// Static remote-schema definitions: { [remoteSchemaName]: SDL | { sdl } |
|
|
275
|
+
// { introspection } }. wasm can't introspect over the network, so each is
|
|
276
|
+
// turned into the introspection response the engine merges into the schema.
|
|
277
|
+
const introspections = Object.fromEntries(
|
|
278
|
+
Object.entries(remotes ?? {}).map(([name, def]) => [
|
|
279
|
+
name,
|
|
280
|
+
remoteSchemaToIntrospection(def),
|
|
281
|
+
]),
|
|
282
|
+
);
|
|
283
|
+
return JSON.parse(await sim_init(metaPayload, JSON.stringify(introspections)));
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
if (metadataJson !== undefined) {
|
|
287
|
+
const result = await init(metadataJson, remoteSchemas ?? {});
|
|
288
|
+
if (result.error) {
|
|
289
|
+
throw new Error(`hasura-sim: sim_init failed: ${result.error}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
db: pg,
|
|
295
|
+
init,
|
|
296
|
+
async version() {
|
|
297
|
+
return sim_version();
|
|
298
|
+
},
|
|
299
|
+
async execute({ query, variables, role, sessionVariables } = {}) {
|
|
300
|
+
const raw = await sim_execute(
|
|
301
|
+
role ?? "admin",
|
|
302
|
+
JSON.stringify(sessionVariables ?? {}),
|
|
303
|
+
query,
|
|
304
|
+
JSON.stringify(variables ?? {}),
|
|
305
|
+
);
|
|
306
|
+
return JSON.parse(raw);
|
|
307
|
+
},
|
|
308
|
+
// Run the standard GraphQL introspection for a role and return the
|
|
309
|
+
// introspection result ({ __schema: … }). The schema is role-scoped, so
|
|
310
|
+
// pass the role (and any session vars the role expects).
|
|
311
|
+
async introspect({ role, sessionVariables } = {}) {
|
|
312
|
+
const res = await this.execute({
|
|
313
|
+
query: getIntrospectionQuery(),
|
|
314
|
+
role,
|
|
315
|
+
sessionVariables,
|
|
316
|
+
});
|
|
317
|
+
if (res.errors) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`hasura-sim: introspection failed: ${JSON.stringify(res.errors)}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return res.data;
|
|
323
|
+
},
|
|
324
|
+
// Role-scoped schema as SDL — suitable as a static codegen base, generated
|
|
325
|
+
// entirely offline.
|
|
326
|
+
async schemaSDL({ role, sessionVariables } = {}) {
|
|
327
|
+
const introspection = await this.introspect({ role, sessionVariables });
|
|
328
|
+
return printSchema(buildClientSchema(introspection));
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
package/src/wire.mjs
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Minimal PostgreSQL extended-query wire-protocol codec.
|
|
2
|
+
//
|
|
3
|
+
// The Haskell pg-client bridge (postgresql-libpq shim) hands us a request of
|
|
4
|
+
// { sql, params: [{oid,fmt,val(base64)}|null], rfmt } and expects back
|
|
5
|
+
// { status, fields: [{name,oid}], rows: [[base64|null]], cmd, error }. To
|
|
6
|
+
// reproduce exactly what a real Postgres would return (binary params in,
|
|
7
|
+
// text/binary results out), we drive pglite via its raw wire protocol rather
|
|
8
|
+
// than its JS-value query() API.
|
|
9
|
+
|
|
10
|
+
const enc = new TextEncoder();
|
|
11
|
+
|
|
12
|
+
class Writer {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.chunks = [];
|
|
15
|
+
}
|
|
16
|
+
u8(n) {
|
|
17
|
+
this.chunks.push(Uint8Array.of(n & 0xff));
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
i16(n) {
|
|
21
|
+
const b = new Uint8Array(2);
|
|
22
|
+
new DataView(b.buffer).setInt16(0, n);
|
|
23
|
+
this.chunks.push(b);
|
|
24
|
+
return this;
|
|
25
|
+
}
|
|
26
|
+
i32(n) {
|
|
27
|
+
const b = new Uint8Array(4);
|
|
28
|
+
new DataView(b.buffer).setInt32(0, n);
|
|
29
|
+
this.chunks.push(b);
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
bytes(u8) {
|
|
33
|
+
this.chunks.push(u8);
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
cstr(s) {
|
|
37
|
+
this.chunks.push(enc.encode(s));
|
|
38
|
+
this.chunks.push(Uint8Array.of(0));
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
build() {
|
|
42
|
+
const len = this.chunks.reduce((a, c) => a + c.length, 0);
|
|
43
|
+
const out = new Uint8Array(len);
|
|
44
|
+
let o = 0;
|
|
45
|
+
for (const c of this.chunks) {
|
|
46
|
+
out.set(c, o);
|
|
47
|
+
o += c.length;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A typed frontend message: 1-byte tag + int32 length (covering length + body).
|
|
54
|
+
function frame(tag, bodyWriter) {
|
|
55
|
+
const body = bodyWriter.build();
|
|
56
|
+
const w = new Writer();
|
|
57
|
+
w.u8(tag.charCodeAt(0));
|
|
58
|
+
w.i32(body.length + 4);
|
|
59
|
+
w.bytes(body);
|
|
60
|
+
return w.build();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function concat(arrays) {
|
|
64
|
+
const len = arrays.reduce((a, c) => a + c.length, 0);
|
|
65
|
+
const out = new Uint8Array(len);
|
|
66
|
+
let o = 0;
|
|
67
|
+
for (const a of arrays) {
|
|
68
|
+
out.set(a, o);
|
|
69
|
+
o += a.length;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Simple query protocol ('Q'): a single message carrying the SQL text. Unlike
|
|
75
|
+
// the extended protocol it permits multiple statements (e.g. Hasura's
|
|
76
|
+
// "SET a; SET b;" connection setup) and needs no Parse/Bind. Results come back
|
|
77
|
+
// as text, which is what the bridge's rfmt=0 path expects.
|
|
78
|
+
export function buildSimpleQuery(sql) {
|
|
79
|
+
return frame("Q", new Writer().cstr(sql));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Build Parse + Bind + Describe(portal) + Execute + Sync for one statement.
|
|
83
|
+
export function buildExtendedQuery({ sql, params, rfmt }) {
|
|
84
|
+
const parse = frame(
|
|
85
|
+
"P",
|
|
86
|
+
new Writer()
|
|
87
|
+
.cstr("") // unnamed prepared statement
|
|
88
|
+
.cstr(sql)
|
|
89
|
+
.i16(params.length)
|
|
90
|
+
.bytes(
|
|
91
|
+
concat(
|
|
92
|
+
params.map((p) => new Writer().i32(p ? p.oid : 0).build()),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const bindBody = new Writer()
|
|
98
|
+
.cstr("") // unnamed portal
|
|
99
|
+
.cstr("") // unnamed statement
|
|
100
|
+
.i16(params.length); // per-param format codes
|
|
101
|
+
for (const p of params) bindBody.i16(p ? p.fmt : 0);
|
|
102
|
+
bindBody.i16(params.length); // param values
|
|
103
|
+
for (const p of params) {
|
|
104
|
+
if (!p) {
|
|
105
|
+
bindBody.i32(-1); // SQL NULL
|
|
106
|
+
} else {
|
|
107
|
+
const bytes = Buffer.from(p.val, "base64");
|
|
108
|
+
bindBody.i32(bytes.length).bytes(new Uint8Array(bytes));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
bindBody.i16(1).i16(rfmt); // one result-format code, applied to all columns
|
|
112
|
+
const bind = frame("B", bindBody);
|
|
113
|
+
|
|
114
|
+
const describe = frame("D", new Writer().u8("P".charCodeAt(0)).cstr(""));
|
|
115
|
+
const execute = frame("E", new Writer().cstr("").i32(0)); // no row limit
|
|
116
|
+
const sync = frame("S", new Writer());
|
|
117
|
+
|
|
118
|
+
return concat([parse, bind, describe, execute, sync]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Decode the backend's raw response bytes into the bridge JSON shape.
|
|
122
|
+
export function decodeResponse(buf) {
|
|
123
|
+
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
124
|
+
const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
|
125
|
+
let off = 0;
|
|
126
|
+
|
|
127
|
+
let fields = [];
|
|
128
|
+
const rows = [];
|
|
129
|
+
let cmd = "";
|
|
130
|
+
let sawRowDescription = false;
|
|
131
|
+
let error = null;
|
|
132
|
+
let empty = false;
|
|
133
|
+
|
|
134
|
+
const readCStringFrom = (start) => {
|
|
135
|
+
let i = start;
|
|
136
|
+
while (u8[i] !== 0) i++;
|
|
137
|
+
return [Buffer.from(u8.subarray(start, i)).toString("utf8"), i + 1];
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
while (off + 5 <= u8.length) {
|
|
141
|
+
const tag = String.fromCharCode(u8[off]);
|
|
142
|
+
const len = dv.getInt32(off + 1);
|
|
143
|
+
const bodyStart = off + 5;
|
|
144
|
+
const bodyEnd = off + 1 + len;
|
|
145
|
+
off = bodyEnd;
|
|
146
|
+
|
|
147
|
+
switch (tag) {
|
|
148
|
+
case "T": {
|
|
149
|
+
// RowDescription
|
|
150
|
+
sawRowDescription = true;
|
|
151
|
+
let p = bodyStart;
|
|
152
|
+
const count = dv.getInt16(p);
|
|
153
|
+
p += 2;
|
|
154
|
+
fields = [];
|
|
155
|
+
for (let i = 0; i < count; i++) {
|
|
156
|
+
const [name, next] = readCStringFrom(p);
|
|
157
|
+
p = next;
|
|
158
|
+
// tableOID(4) colAttr(2) typeOID(4) typeLen(2) typeMod(4) fmt(2)
|
|
159
|
+
const typeOID = dv.getInt32(p + 6);
|
|
160
|
+
p += 18;
|
|
161
|
+
fields.push({ name, oid: typeOID });
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "D": {
|
|
166
|
+
// DataRow
|
|
167
|
+
let p = bodyStart;
|
|
168
|
+
const count = dv.getInt16(p);
|
|
169
|
+
p += 2;
|
|
170
|
+
const cells = [];
|
|
171
|
+
for (let i = 0; i < count; i++) {
|
|
172
|
+
const cellLen = dv.getInt32(p);
|
|
173
|
+
p += 4;
|
|
174
|
+
if (cellLen === -1) {
|
|
175
|
+
cells.push(null);
|
|
176
|
+
} else {
|
|
177
|
+
cells.push(
|
|
178
|
+
Buffer.from(u8.subarray(p, p + cellLen)).toString("base64"),
|
|
179
|
+
);
|
|
180
|
+
p += cellLen;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
rows.push(cells);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case "C": {
|
|
187
|
+
// CommandComplete
|
|
188
|
+
[cmd] = readCStringFrom(bodyStart);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case "I": {
|
|
192
|
+
empty = true;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case "E": {
|
|
196
|
+
// ErrorResponse: fieldCode byte + cstring, terminated by 0 byte.
|
|
197
|
+
let p = bodyStart;
|
|
198
|
+
const fieldsMap = {};
|
|
199
|
+
while (u8[p] !== 0) {
|
|
200
|
+
const code = String.fromCharCode(u8[p]);
|
|
201
|
+
p += 1;
|
|
202
|
+
const [val, next] = readCStringFrom(p);
|
|
203
|
+
p = next;
|
|
204
|
+
fieldsMap[code] = val;
|
|
205
|
+
}
|
|
206
|
+
error = {
|
|
207
|
+
sqlstate: fieldsMap.C,
|
|
208
|
+
message: fieldsMap.M,
|
|
209
|
+
detail: fieldsMap.D,
|
|
210
|
+
hint: fieldsMap.H,
|
|
211
|
+
};
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
default:
|
|
215
|
+
// ParseComplete(1), BindComplete(2), NoData(n), ReadyForQuery(Z),
|
|
216
|
+
// ParameterDescription(t), etc. — ignored.
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let status;
|
|
222
|
+
if (error) status = "FatalError";
|
|
223
|
+
else if (empty) status = "EmptyQuery";
|
|
224
|
+
else if (sawRowDescription) status = "TuplesOk";
|
|
225
|
+
else status = "CommandOk";
|
|
226
|
+
|
|
227
|
+
const result = { status, fields, rows, cmd };
|
|
228
|
+
if (error) result.error = error;
|
|
229
|
+
return result;
|
|
230
|
+
}
|
package/wasm/reactor.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// This file implements the JavaScript runtime logic for Haskell
|
|
2
|
+
// modules that use JSFFI. It is not an ESM module, but the template
|
|
3
|
+
// of one; the post-linker script will copy all contents into a new
|
|
4
|
+
// ESM module.
|
|
5
|
+
|
|
6
|
+
// Manage a mapping from 32-bit ids to actual JavaScript values.
|
|
7
|
+
class JSValManager {
|
|
8
|
+
#lastk = 0;
|
|
9
|
+
#kv = new Map();
|
|
10
|
+
|
|
11
|
+
newJSVal(v) {
|
|
12
|
+
const k = ++this.#lastk;
|
|
13
|
+
this.#kv.set(k, v);
|
|
14
|
+
return k;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A separate has() call to ensure we can store undefined as a value
|
|
18
|
+
// too. Also, unconditionally check this since the check is cheap
|
|
19
|
+
// anyway, if the check fails then there's a use-after-free to be
|
|
20
|
+
// fixed.
|
|
21
|
+
getJSVal(k) {
|
|
22
|
+
if (!this.#kv.has(k)) {
|
|
23
|
+
throw new WebAssembly.RuntimeError(`getJSVal(${k})`);
|
|
24
|
+
}
|
|
25
|
+
return this.#kv.get(k);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check for double free as well.
|
|
29
|
+
freeJSVal(k) {
|
|
30
|
+
if (!this.#kv.delete(k)) {
|
|
31
|
+
throw new WebAssembly.RuntimeError(`freeJSVal(${k})`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The actual setImmediate() to be used. This is a ESM module top
|
|
37
|
+
// level binding and doesn't pollute the globalThis namespace.
|
|
38
|
+
//
|
|
39
|
+
// To benchmark different setImmediate() implementations in the
|
|
40
|
+
// browser, use https://github.com/jphpsf/setImmediate-shim-demo as a
|
|
41
|
+
// starting point.
|
|
42
|
+
const setImmediate = await (async () => {
|
|
43
|
+
// node, bun, or other scripts might have set this up in the browser
|
|
44
|
+
if (globalThis.setImmediate) {
|
|
45
|
+
return globalThis.setImmediate;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// deno
|
|
49
|
+
if (globalThis.Deno) {
|
|
50
|
+
try {
|
|
51
|
+
return (await import("node:timers")).setImmediate;
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/postTask
|
|
56
|
+
if (globalThis.scheduler) {
|
|
57
|
+
return (cb, ...args) => scheduler.postTask(() => cb(...args));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Cloudflare workers doesn't support MessageChannel
|
|
61
|
+
if (globalThis.MessageChannel) {
|
|
62
|
+
// A simple & fast setImmediate() implementation for browsers. It's
|
|
63
|
+
// not a drop-in replacement for node.js setImmediate() because:
|
|
64
|
+
// 1. There's no clearImmediate(), and setImmediate() doesn't return
|
|
65
|
+
// anything
|
|
66
|
+
// 2. There's no guarantee that callbacks scheduled by setImmediate()
|
|
67
|
+
// are executed in the same order (in fact it's the opposite lol),
|
|
68
|
+
// but you are never supposed to rely on this assumption anyway
|
|
69
|
+
class SetImmediate {
|
|
70
|
+
#fs = [];
|
|
71
|
+
#mc = new MessageChannel();
|
|
72
|
+
|
|
73
|
+
constructor() {
|
|
74
|
+
this.#mc.port1.addEventListener("message", () => {
|
|
75
|
+
this.#fs.pop()();
|
|
76
|
+
});
|
|
77
|
+
this.#mc.port1.start();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setImmediate(cb, ...args) {
|
|
81
|
+
this.#fs.push(() => cb(...args));
|
|
82
|
+
this.#mc.port2.postMessage(undefined);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const sm = new SetImmediate();
|
|
87
|
+
return (cb, ...args) => sm.setImmediate(cb, ...args);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (cb, ...args) => setTimeout(cb, 0, ...args);
|
|
91
|
+
})();
|
|
92
|
+
|
|
93
|
+
export default (__exports) => {
|
|
94
|
+
const __ghc_wasm_jsffi_jsval_manager = new JSValManager();
|
|
95
|
+
const __ghc_wasm_jsffi_finalization_registry = globalThis.FinalizationRegistry ? new FinalizationRegistry(sp => __exports.rts_freeStablePtr(sp)) : { register: () => {}, unregister: () => true };
|
|
96
|
+
return {
|
|
97
|
+
newJSVal: (v) => __ghc_wasm_jsffi_jsval_manager.newJSVal(v),
|
|
98
|
+
getJSVal: (k) => __ghc_wasm_jsffi_jsval_manager.getJSVal(k),
|
|
99
|
+
freeJSVal: (k) => __ghc_wasm_jsffi_jsval_manager.freeJSVal(k),
|
|
100
|
+
scheduleWork: () => setImmediate(__exports.rts_schedulerLoop),
|
|
101
|
+
ZC7ZCghczminternalZCGHCziInternalziWasmziPrimziImportsZC: ($1,$2) => ($1.then(res => __exports.rts_promiseResolveWord32($2, res), err => __exports.rts_promiseReject($2, err))),
|
|
102
|
+
ZC17ZCghczminternalZCGHCziInternalziWasmziPrimziImportsZC: ($1,$2) => ($1.then(res => __exports.rts_promiseResolveJSVal($2, res), err => __exports.rts_promiseReject($2, err))),
|
|
103
|
+
ZC18ZCghczminternalZCGHCziInternalziWasmziPrimziImportsZC: ($1,$2) => ($1.then(() => __exports.rts_promiseResolveUnit($2), err => __exports.rts_promiseReject($2, err))),
|
|
104
|
+
ZC0ZCghczminternalZCGHCziInternalziWasmziPrimziConcziInternalZC: async ($1) => (new Promise(res => setTimeout(res, $1 / 1000))),
|
|
105
|
+
ZC0ZCghczminternalZCGHCziInternalziWasmziPrimziTypesZC: ($1) => (`${$1.stack ? $1.stack : $1}`),
|
|
106
|
+
ZC1ZCghczminternalZCGHCziInternalziWasmziPrimziTypesZC: ($1,$2) => ((new TextDecoder('utf-8', {fatal: true})).decode(new Uint8Array(__exports.memory.buffer, $1, $2))),
|
|
107
|
+
ZC2ZCghczminternalZCGHCziInternalziWasmziPrimziTypesZC: ($1,$2,$3) => ((new TextEncoder()).encodeInto($1, new Uint8Array(__exports.memory.buffer, $2, $3)).written),
|
|
108
|
+
ZC3ZCghczminternalZCGHCziInternalziWasmziPrimziTypesZC: ($1) => ($1.length),
|
|
109
|
+
ZC4ZCghczminternalZCGHCziInternalziWasmziPrimziTypesZC: ($1) => {try { __ghc_wasm_jsffi_finalization_registry.unregister($1); } catch {}},
|
|
110
|
+
ZC0ZCpostgresqlzmlibpqzm0zi11zi0zi0zminplaceZCDatabaseziPostgreSQLziLibPQziBridgeZC: async ($1,$2) => (pg_exec($1,$2)),
|
|
111
|
+
ZC1ZCpostgresqlzmlibpqzm0zi11zi0zi0zminplaceZCDatabaseziPostgreSQLziLibPQziBridgeZC: async ($1) => (pg_connect($1)),
|
|
112
|
+
ZC0ZChasurazmwasmzmsimzm1zi0zi0zminplaceZCHasuraziWasmSimziHttpBridgeziFfiZC: async ($1) => (js_http_exec($1)),
|
|
113
|
+
ZC0ZCghczminternalZCGHCziInternalziWasmziPrimziExportsZC: ($1,$2) => ($1.reject(new WebAssembly.RuntimeError($2))),
|
|
114
|
+
ZC18ZCghczminternalZCGHCziInternalziWasmziPrimziExportsZC: ($1,$2) => ($1.resolve($2)),
|
|
115
|
+
ZC20ZCghczminternalZCGHCziInternalziWasmziPrimziExportsZC: ($1) => {$1.throwTo = () => {};},
|
|
116
|
+
ZC21ZCghczminternalZCGHCziInternalziWasmziPrimziExportsZC: ($1,$2) => {$1.throwTo = (err) => __exports.rts_promiseThrowTo($2, err);},
|
|
117
|
+
ZC22ZCghczminternalZCGHCziInternalziWasmziPrimziExportsZC: () => {let res, rej; const p = new Promise((resolve, reject) => { res = resolve; rej = reject; }); p.resolve = res; p.reject = rej; return p;},
|
|
118
|
+
};
|
|
119
|
+
};
|
|
Binary file
|