duckfn-docs-kit 0.1.0 → 0.2.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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Node-side collector for runnable SQL blocks: walks a docs site's content and
3
+ * returns every fenced block whose info string is a runnable config.
4
+ *
5
+ * The metastring contract belongs to `sql/remark.ts` (one parser, exported as
6
+ * `parseRunnableSqlMeta`), so the blocks collected here are exactly the ones
7
+ * the build turns into `<dfk-sql>` — a CI check over this list therefore covers
8
+ * what the site actually publishes.
9
+ *
10
+ * Fences follow CommonMark closely enough for a docs tree: a closing fence has
11
+ * to use the same character, be at least as long, and carry no info string.
12
+ * That is what keeps a ```sql example *inside* a ````md wrapper (as
13
+ * `docs-kit/runnable-sql.md` shows the metastring) from being collected as a
14
+ * block of its own.
15
+ */
16
+ import {readFileSync, readdirSync, statSync} from 'node:fs';
17
+ import {join, relative, sep} from 'node:path';
18
+
19
+ import {parseRunnableSqlMeta, type RunnableSqlConfig} from './remark';
20
+
21
+ /** One runnable block, positioned so a failure can name it. */
22
+ export interface RunnableSqlBlock {
23
+ /** Path relative to the site root, always with forward slashes. */
24
+ file: string;
25
+ /** 1-based line of the opening fence. */
26
+ line: number;
27
+ config: RunnableSqlConfig;
28
+ sql: string;
29
+ }
30
+
31
+ export interface CollectRunnableSqlOptions {
32
+ /** Site root the reported paths are relative to, and the base of a relative dir. */
33
+ siteDir: string;
34
+ /**
35
+ * Directories to scan (absolute, or relative to `siteDir`). Missing ones are
36
+ * skipped rather than reported: a site may have no translations.
37
+ */
38
+ contentDirs: readonly string[];
39
+ /** File extensions to scan; both `.md` and `.mdx` are markdown to us. */
40
+ extensions?: readonly string[];
41
+ }
42
+
43
+ const DEFAULT_EXTENSIONS = ['.md', '.mdx'] as const;
44
+
45
+ /** Every runnable block under `contentDirs`, in file order, then line order. */
46
+ export function collectRunnableSql(options: CollectRunnableSqlOptions): RunnableSqlBlock[] {
47
+ const {siteDir, contentDirs, extensions = DEFAULT_EXTENSIONS} = options;
48
+ const files: string[] = [];
49
+ for (const dir of contentDirs) {
50
+ collectFiles(join(siteDir, dir), extensions, files);
51
+ }
52
+ const blocks: RunnableSqlBlock[] = [];
53
+ for (const file of files.sort()) {
54
+ for (const block of blocksOf(readFileSync(file, 'utf8'))) {
55
+ blocks.push({
56
+ ...block,
57
+ file: relative(siteDir, file).split(sep).join('/'),
58
+ });
59
+ }
60
+ }
61
+ return blocks;
62
+ }
63
+
64
+ function collectFiles(dir: string, extensions: readonly string[], out: string[]): void {
65
+ let entries: string[];
66
+ try {
67
+ entries = readdirSync(dir);
68
+ } catch {
69
+ // Nothing to scan here (typically a translation that does not exist yet).
70
+ return;
71
+ }
72
+ for (const name of entries) {
73
+ const path = join(dir, name);
74
+ if (statSync(path).isDirectory()) {
75
+ collectFiles(path, extensions, out);
76
+ } else if (extensions.some((extension) => name.endsWith(extension))) {
77
+ out.push(path);
78
+ }
79
+ }
80
+ }
81
+
82
+ interface RawBlock {
83
+ line: number;
84
+ config: RunnableSqlConfig;
85
+ sql: string;
86
+ }
87
+
88
+ /** The runnable blocks of one markdown document. */
89
+ function blocksOf(text: string): RawBlock[] {
90
+ const lines = text.split('\n');
91
+ const out: RawBlock[] = [];
92
+ let open: {marker: string; info: string; line: number} | null = null;
93
+ let body: string[] = [];
94
+
95
+ for (let index = 0; index < lines.length; index++) {
96
+ const line = lines[index] ?? '';
97
+ const fence = /^(`{3,}|~{3,})(.*)$/.exec(line);
98
+ if (!fence) {
99
+ if (open) {
100
+ body.push(line);
101
+ }
102
+ continue;
103
+ }
104
+ const [marker, info] = [fence[1] ?? '', (fence[2] ?? '').trim()];
105
+ if (!open) {
106
+ open = {marker, info, line: index + 1};
107
+ body = [];
108
+ continue;
109
+ }
110
+ const closes =
111
+ marker.charAt(0) === open.marker.charAt(0) && marker.length >= open.marker.length && info === '';
112
+ if (!closes) {
113
+ // A shorter or differently marked fence is content of the open block.
114
+ body.push(line);
115
+ continue;
116
+ }
117
+ if (open.info.startsWith('sql')) {
118
+ const config = parseRunnableSqlMeta(open.info.slice('sql'.length).trim());
119
+ if (config) {
120
+ out.push({line: open.line, config, sql: body.join('\n')});
121
+ }
122
+ }
123
+ open = null;
124
+ body = [];
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * Whether a block is expected to fail — the block's own `"expect": "error"`
131
+ * metadata, never its prose or its comments: an expectation that the SQL test
132
+ * suite acts on has to be data, not a string match on a comment.
133
+ */
134
+ export function expectsError(config: RunnableSqlConfig): boolean {
135
+ return config.expect === 'error';
136
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Runs runnable SQL blocks in DuckDB-Wasm from Node, on the same architecture
3
+ * the site itself uses: the official Node **worker** target, one instance per
4
+ * page, one connection per page.
5
+ *
6
+ * Three platform facts shape this file, all of them measured rather than
7
+ * guessed (duckfn's AGENTS.md records the investigation):
8
+ *
9
+ * 1. The official Node *blocking* target (`duckdb-node-blocking.cjs`) deadlocks
10
+ * when it loads an extension that opens its own connection while registering
11
+ * — which is exactly what a docs site's extension may do. The worker target
12
+ * is the only Node target that works.
13
+ * 2. A bare file name in `LOAD` is not usable on wasm: it hangs, without an
14
+ * error, even with the file registered through `registerFileBuffer`. The
15
+ * extension has to be fetched over http(s), like the site does it.
16
+ * 3. DuckDB stages that URL under
17
+ * `~/.duckdb/extensions/<host>[:<port>]/<first path segment>/` — which is
18
+ * why the served URL keeps a path segment, and why the directory is
19
+ * pre-created: the loader's own `mkdir` is non-recursive. A colon is illegal
20
+ * in a Windows path, so the port plan is per platform: Windows takes the
21
+ * default port 80 (the URL then carries no port at all), POSIX takes any
22
+ * free port. The directory's previous content is removed so a stale copy can
23
+ * never be what runs.
24
+ */
25
+ import {createServer, type Server} from 'node:http';
26
+ import {Worker as WorkerThread} from 'node:worker_threads';
27
+ import {createRequire} from 'node:module';
28
+ import {mkdirSync, readFileSync, rmSync} from 'node:fs';
29
+ import {homedir} from 'node:os';
30
+ import {basename, dirname, join} from 'node:path';
31
+
32
+ /** The slice of the Node target's surface this runner uses (it ships no types). */
33
+ interface DuckdbNodeTarget {
34
+ AsyncDuckDB: new (logger: unknown, worker: unknown) => DuckdbDatabase;
35
+ ConsoleLogger: new () => unknown;
36
+ }
37
+
38
+ export interface DuckdbQueryResult {
39
+ numRows: number;
40
+ schema: {fields: {name: string}[]};
41
+ }
42
+
43
+ export interface DuckdbConnection {
44
+ query(sql: string): Promise<DuckdbQueryResult>;
45
+ }
46
+
47
+ interface DuckdbDatabase {
48
+ instantiate(mainModule: string, pthreadWorker: string | null): Promise<void>;
49
+ open(config: {allowUnsignedExtensions?: boolean}): Promise<void>;
50
+ connect(): Promise<DuckdbConnection>;
51
+ terminate(): Promise<void>;
52
+ }
53
+
54
+ const require = createRequire(import.meta.url);
55
+
56
+ /** Loaded through a variable so a bundler leaves the dynamic import alone. */
57
+ const NODE_TARGET = '@duckdb/duckdb-wasm/dist/duckdb-node.cjs';
58
+
59
+ export type WasmPlatform = 'eh' | 'mvp';
60
+
61
+ export interface RunnerOptions {
62
+ /**
63
+ * The extension to `LOAD`: a local `.duckdb_extension.wasm` path (served to
64
+ * the worker over a loopback http server) or an absolute `http(s)` URL.
65
+ */
66
+ extension: string;
67
+ /**
68
+ * DuckDB-Wasm platform. Must match how the extension was built — a site
69
+ * serving `duckfn-wasm_eh.duckdb_extension.wasm` runs the `eh` bundle.
70
+ */
71
+ platform?: WasmPlatform;
72
+ /** The engine wasm; defaults to the one shipped beside the worker bundle. */
73
+ engine?: string;
74
+ }
75
+
76
+ export interface RunResult {
77
+ rows: number;
78
+ columns: number;
79
+ }
80
+
81
+ /**
82
+ * The WebWorker surface `AsyncDuckDB` expects, backed by a worker thread.
83
+ *
84
+ * The official `createWorker()` cannot be reused here: it fetches the worker
85
+ * script, wraps it in a blob URL and resolves it as a file path, which is the
86
+ * browser's module story. The contract it relies on internally is small —
87
+ * `worker_threads` entry with `workerData.mod` — and that is what this
88
+ * reproduces.
89
+ */
90
+ class WorkerShim {
91
+ #thread: WorkerThread;
92
+ #listeners = new Map<string, ((event: unknown) => void)[]>();
93
+ onmessage: ((event: unknown) => void) | null = null;
94
+ onerror: ((event: unknown) => void) | null = null;
95
+ onclose: ((event: unknown) => void) | null = null;
96
+
97
+ constructor(mod: string) {
98
+ this.#thread = new WorkerThread(require.resolve(NODE_TARGET), {
99
+ workerData: {mod, name: '', type: ''},
100
+ });
101
+ this.#thread.on('message', (data) => this.#emit('message', data));
102
+ this.#thread.on('error', (error) => this.#emit('error', error));
103
+ this.#thread.on('exit', () => this.#emit('close'));
104
+ }
105
+
106
+ #emit(type: string, data?: unknown): void {
107
+ const event = {type, data, target: this, currentTarget: this};
108
+ const handler = (this as Record<string, unknown>)[`on${type}`];
109
+ if (typeof handler === 'function') {
110
+ (handler as (event: unknown) => void)(event);
111
+ }
112
+ for (const listener of this.#listeners.get(type) ?? []) {
113
+ listener(event);
114
+ }
115
+ }
116
+
117
+ addEventListener(type: string, listener: (event: unknown) => void): void {
118
+ this.#listeners.set(type, [...(this.#listeners.get(type) ?? []), listener]);
119
+ }
120
+
121
+ removeEventListener(type: string, listener: (event: unknown) => void): void {
122
+ this.#listeners.set(
123
+ type,
124
+ (this.#listeners.get(type) ?? []).filter((candidate) => candidate !== listener),
125
+ );
126
+ }
127
+
128
+ postMessage(data: unknown, transfer?: unknown): void {
129
+ // The worker_threads signature always wants the second argument.
130
+ this.#thread.postMessage(data, (transfer ?? []) as never);
131
+ }
132
+
133
+ terminate(): Promise<number> | void {
134
+ return this.#thread.terminate();
135
+ }
136
+ }
137
+
138
+ /**
139
+ * One DuckDB-Wasm instance at a time, driven from Node.
140
+ *
141
+ * `newPage()` is what a docs site does per page load: a fresh instance, a fresh
142
+ * connection, the extension loaded again. Blocks of one page then share state
143
+ * (a table created in one block is visible to the next), while pages stay
144
+ * isolated — which is why the runner is used one page at a time rather than
145
+ * over a single long-lived connection.
146
+ */
147
+ export class WasmSqlRunner {
148
+ #db: DuckdbDatabase | null = null;
149
+ #conn: DuckdbConnection | null = null;
150
+ #server: Server | null = null;
151
+ #stagingDir: string | null = null;
152
+ readonly #extensionUrl: string;
153
+ readonly #engine: string;
154
+ readonly #workerBundle: string;
155
+
156
+ private constructor(extensionUrl: string, engine: string, workerBundle: string) {
157
+ this.#extensionUrl = extensionUrl;
158
+ this.#engine = engine;
159
+ this.#workerBundle = workerBundle;
160
+ }
161
+
162
+ static async create(options: RunnerOptions): Promise<WasmSqlRunner> {
163
+ const platform = options.platform ?? 'eh';
164
+ const workerBundle = require.resolve(
165
+ `@duckdb/duckdb-wasm/dist/duckdb-node-${platform}.worker.cjs`,
166
+ );
167
+ const engine = options.engine ?? join(dirname(workerBundle), `duckdb-${platform}.wasm`);
168
+
169
+ const remote = /^https?:\/\//i.test(options.extension);
170
+ const served = remote ? null : await serveExtension(options.extension);
171
+ const url = served ? served.url : options.extension;
172
+
173
+ const runner = new WasmSqlRunner(url, engine, workerBundle);
174
+ runner.#server = served ? served.server : null;
175
+ runner.#stagingDir = served
176
+ ? prepareStagingDir(served.stagingHost, served.stagingSegment)
177
+ : null;
178
+ return runner;
179
+ }
180
+
181
+ /** Drops the current instance and starts a fresh page: new instance, new connection. */
182
+ async newPage(): Promise<void> {
183
+ if (this.#db) {
184
+ await this.#db.terminate();
185
+ this.#db = null;
186
+ this.#conn = null;
187
+ }
188
+ const duckdb = (await import(/* @vite-ignore */ NODE_TARGET)) as unknown as DuckdbNodeTarget;
189
+ const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), new WorkerShim(this.#workerBundle));
190
+ await db.instantiate(this.#engine, null);
191
+ await db.open({allowUnsignedExtensions: true});
192
+ const conn = await db.connect();
193
+ await conn.query(`LOAD '${this.#extensionUrl}'`);
194
+ this.#db = db;
195
+ this.#conn = conn;
196
+ }
197
+
198
+ /** Runs one block; the caller decides whether a failure is expected. */
199
+ async run(sql: string): Promise<RunResult> {
200
+ const conn = this.#conn;
201
+ if (!conn) {
202
+ throw new Error('sql/verify: no page is open — call newPage() first');
203
+ }
204
+ const table = await conn.query(sql);
205
+ return {rows: table.numRows, columns: table.schema.fields.length};
206
+ }
207
+
208
+ /** Where the fetched extension was staged, for diagnostics. */
209
+ get stagingDir(): string | null {
210
+ return this.#stagingDir;
211
+ }
212
+
213
+ async close(): Promise<void> {
214
+ if (this.#db) {
215
+ await this.#db.terminate();
216
+ this.#db = null;
217
+ this.#conn = null;
218
+ }
219
+ if (this.#server) {
220
+ await new Promise<void>((resolve) => this.#server?.close(() => resolve()));
221
+ this.#server = null;
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Serves one extension file over loopback http, because wasm `LOAD` only works
228
+ * with a URL (see the file header). The server answers any path with that one
229
+ * file: it is a local, short-lived convenience, not a web server.
230
+ *
231
+ * The port choice is the platform split from the file header: the default port
232
+ * where the platform lets a non-root process take it (so the URL stays
233
+ * port-less, which is what Windows needs), any free port otherwise.
234
+ */
235
+ async function serveExtension(file: string): Promise<{
236
+ server: Server;
237
+ url: string;
238
+ /** The directory DuckDB will stage the download under, and its parent. */
239
+ stagingHost: string;
240
+ stagingSegment: string;
241
+ }> {
242
+ const body = readFileSync(file);
243
+ const name = basename(file);
244
+ // DuckDB stages a fetched extension under
245
+ // `~/.duckdb/extensions/<host>/<first path segment>/`, so the URL needs one
246
+ // path segment — the site serves its extension from `duckdb-extensions/`, and
247
+ // this mirrors that shape with the extension's own name.
248
+ const segment = extensionName(file);
249
+ const server = createServer((_request, response) => {
250
+ response.writeHead(200, {
251
+ 'content-type': 'application/octet-stream',
252
+ 'content-length': body.length,
253
+ });
254
+ response.end(body);
255
+ });
256
+ const wanted = process.platform === 'win32' ? 80 : 0;
257
+ await new Promise<void>((resolve, reject) => {
258
+ server.once('error', (error: NodeJS.ErrnoException) => {
259
+ reject(
260
+ new Error(
261
+ `sql/verify: cannot serve the extension on port ${wanted} (${error.code}): a port in ` +
262
+ `the URL would put a colon into DuckDB's staging path, which is not a legal Windows ` +
263
+ `path — free port 80, or load the extension from an http(s) URL with --extension`,
264
+ ),
265
+ );
266
+ });
267
+ server.listen(wanted, 'localhost', resolve);
268
+ });
269
+ const address = server.address();
270
+ const bound = typeof address === 'object' && address ? address.port : wanted;
271
+ const host = bound === 80 ? 'localhost' : `localhost:${bound}`;
272
+ return {
273
+ server,
274
+ url: `http://${host}/${segment}/${name}`,
275
+ stagingHost: host,
276
+ stagingSegment: segment,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Pre-creates DuckDB's staging directory and empties it.
282
+ *
283
+ * The directory has to exist beforehand (the loader's own `mkdir` is
284
+ * non-recursive), and anything left in it from an earlier run would be reused
285
+ * instead of the extension being fetched again — a stale binary silently
286
+ * testing the wrong build.
287
+ */
288
+ function prepareStagingDir(host: string, segment: string): string {
289
+ const dir = join(homedir(), '.duckdb', 'extensions', host, segment);
290
+ rmSync(dir, {recursive: true, force: true});
291
+ mkdirSync(dir, {recursive: true});
292
+ return dir;
293
+ }
294
+
295
+ /** The text before the first dot of the file name — the entry symbol DuckDB looks up. */
296
+ function extensionName(file: string): string {
297
+ return basename(file).split('.')[0] ?? '';
298
+ }
package/src/sql/remark.ts CHANGED
@@ -38,6 +38,17 @@ export interface RunnableSqlConfig {
38
38
  * iframe, so scripts run with an opaque origin.
39
39
  */
40
40
  show?: 'table' | 'html' | 'iframe' | 'svg' | 'text';
41
+ /**
42
+ * What this block is expected to do when the docs' own SQL test suite runs it
43
+ * (`duckfn-docs-kit/sql/verify`). Defaults to `'ok'`; `'error'` marks a block
44
+ * that demonstrates a failure — the suite then *requires* it to fail, and
45
+ * reports it when it unexpectedly succeeds instead.
46
+ *
47
+ * This is the only source of truth for the expectation: the prose around a
48
+ * block, and a `-- error: …` comment inside it, are there for readers, and
49
+ * neither is machine-checked.
50
+ */
51
+ expect?: 'ok' | 'error';
41
52
  /**
42
53
  * The column holding the markup, for the preview renderers. A single-column
43
54
  * result is unambiguous and is used as-is.
@@ -102,8 +113,15 @@ interface ParentNode {
102
113
  children?: unknown[];
103
114
  }
104
115
 
105
- /** Parse the metastring; `null` means "not a runnable block, leave it alone". */
106
- function parseConfig(meta: string | null | undefined): RunnableSqlConfig | null {
116
+ /**
117
+ * Parse the metastring; `null` means "not a runnable block, leave it alone".
118
+ *
119
+ * Exported because the block contract has two consumers: this plugin, which
120
+ * turns a block into `<dfk-sql>` at build time, and `sql/verify` (via
121
+ * `sql/collect`), which runs those same blocks in CI. Both have to agree on
122
+ * what counts as runnable, so there is one parser.
123
+ */
124
+ export function parseRunnableSqlMeta(meta: string | null | undefined): RunnableSqlConfig | null {
107
125
  if (!meta) {
108
126
  return null;
109
127
  }
@@ -199,7 +217,7 @@ export const remarkRunnableSql: Plugin<[RunnableSqlOptions?]> =
199
217
  }
200
218
  const candidate = child as CodeNode;
201
219
  if (candidate.type === 'code' && candidate.lang === 'sql') {
202
- const config = parseConfig(candidate.meta);
220
+ const config = parseRunnableSqlMeta(candidate.meta);
203
221
  if (config) {
204
222
  return wrapRunnableSql(candidate, config);
205
223
  }
package/src/sql/sql.css CHANGED
@@ -1,7 +1,7 @@
1
1
  /* Light-DOM styles for `<dfk-sql>`: the result container. It lives in the
2
2
  element's light DOM (not the shadow root) because VTable injects a
3
3
  *document-level* stylesheet that a shadow boundary could not host — the same
4
- light-DOM exception as the TOC toggle (see AGENTS.md rule 9). It is created
4
+ light-DOM exception as the TOC toggle (see CONVENTIONS.md rule 9). It is created
5
5
  only when a query runs, so the light DOM still starts empty (rule 11). Every
6
6
  class carries the `dfk-sql-` prefix so nothing can collide with the host site.
7
7
  *