@fusedio/widget-sdk 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.
Files changed (43) hide show
  1. package/README.md +169 -0
  2. package/dist/bridge.d.ts +247 -0
  3. package/dist/bridge.js +61 -0
  4. package/dist/bundle.js +2 -0
  5. package/dist/define-catalog.d.ts +53 -0
  6. package/dist/define-catalog.js +3 -0
  7. package/dist/define-component.d.ts +93 -0
  8. package/dist/define-component.js +43 -0
  9. package/dist/form.d.ts +49 -0
  10. package/dist/form.js +136 -0
  11. package/dist/hooks/use-allowed-sources.d.ts +20 -0
  12. package/dist/hooks/use-allowed-sources.js +49 -0
  13. package/dist/hooks/use-allowed-udf-names.d.ts +15 -0
  14. package/dist/hooks/use-allowed-udf-names.js +42 -0
  15. package/dist/hooks/use-canvas-params.d.ts +13 -0
  16. package/dist/hooks/use-canvas-params.js +58 -0
  17. package/dist/hooks/use-duckdb-sql.d.ts +61 -0
  18. package/dist/hooks/use-duckdb-sql.js +558 -0
  19. package/dist/hooks/use-fused-param.d.ts +40 -0
  20. package/dist/hooks/use-fused-param.js +283 -0
  21. package/dist/hooks/use-json-ui-edge-animation.d.ts +22 -0
  22. package/dist/hooks/use-json-ui-edge-animation.js +26 -0
  23. package/dist/hooks/use-json-ui-log.d.ts +33 -0
  24. package/dist/hooks/use-json-ui-log.js +74 -0
  25. package/dist/hooks/use-json-ui-udf-info.d.ts +24 -0
  26. package/dist/hooks/use-json-ui-udf-info.js +23 -0
  27. package/dist/hooks/use-param-substitution.d.ts +22 -0
  28. package/dist/hooks/use-param-substitution.js +207 -0
  29. package/dist/hooks/use-udf-output.d.ts +85 -0
  30. package/dist/hooks/use-udf-output.js +202 -0
  31. package/dist/hooks/use-upload-access-check.d.ts +19 -0
  32. package/dist/hooks/use-upload-access-check.js +39 -0
  33. package/dist/hooks/use-url-signing.d.ts +42 -0
  34. package/dist/hooks/use-url-signing.js +101 -0
  35. package/dist/index.d.ts +35 -0
  36. package/dist/index.js +40 -0
  37. package/dist/protocol.d.ts +39 -0
  38. package/dist/protocol.js +32 -0
  39. package/dist/types.d.ts +84 -0
  40. package/dist/types.js +1 -0
  41. package/dist/utils/sql-placeholders.d.ts +80 -0
  42. package/dist/utils/sql-placeholders.js +204 -0
  43. package/package.json +36 -0
@@ -0,0 +1,558 @@
1
+ /**
2
+ * DuckDB SQL execution against UDF Parquet outputs.
3
+ *
4
+ * Two hooks layered together:
5
+ * - `useDuckDbSqlQueryPreprocessing` parses placeholders, subscribes to
6
+ * `$param` / form values, calls the bridge to register UDFs in DuckDB
7
+ * VFS, signs any `s3://`/`gs://`/`fd://` URL literals, and returns the
8
+ * final ready-to-run SQL string.
9
+ * - `useDuckDbSqlQuery` consumes that processed SQL, runs it via the
10
+ * bridge, and surfaces `{ rows, columns, loading, error, refetch }`.
11
+ *
12
+ * All state-storage details (Jotai atoms, DuckDB wasm, fetcher) live on
13
+ * the host side via the bridge. Both hooks work identically in the
14
+ * workbench, the catalog-template test harness, or any other host.
15
+ */
16
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
17
+ import { useFusedWidgetBridge } from "../bridge";
18
+ import { useFormParams } from "../form";
19
+ import { useJsonUiLog } from "./use-json-ui-log";
20
+ import { useJsonUiEdgeAnimation } from "./use-json-ui-edge-animation";
21
+ import { useCanvasParams } from "./use-canvas-params";
22
+ import { computePlaceholderKey, extractSignableUrls, extractSqlParams, parseSqlUdfPlaceholders, resolveOverrideValue, rewriteSignedUrls, substituteSqlParams, getDollarRefName, } from "../utils/sql-placeholders";
23
+ const DEFAULT_MAX_ROWS = 500;
24
+ const EMPTY_ROWS = Object.freeze([]);
25
+ const EMPTY_COLUMNS = Object.freeze([]);
26
+ function appendLimitIfMissing(sql, maxRows) {
27
+ if (/\bLIMIT\b/i.test(sql))
28
+ return sql;
29
+ const trimmed = sql.trimEnd();
30
+ const withoutTrailingSemicolon = trimmed.endsWith(";")
31
+ ? trimmed.slice(0, -1)
32
+ : trimmed;
33
+ return `${withoutTrailingSemicolon} LIMIT ${maxRows}`;
34
+ }
35
+ function escapeSqlIdentifier(identifier) {
36
+ return `"${identifier.replace(/"/g, '""')}"`;
37
+ }
38
+ function buildProcessedSql(sql, resolved, fileNameMap, sourceOverrides, sqlParamValues, maxRows) {
39
+ // When the entire sql prop is a single $param (e.g. "$sql_query"),
40
+ // use the raw param value directly — it's already a complete SQL string.
41
+ const singleParam = sql.match(/^\$([a-zA-Z_][a-zA-Z0-9_]*)$/);
42
+ if (singleParam) {
43
+ const val = sqlParamValues[singleParam[1]];
44
+ return val == null ? "" : appendLimitIfMissing(String(val), maxRows);
45
+ }
46
+ // Walk right-to-left so earlier offsets stay valid as we splice.
47
+ let processedSql = sql;
48
+ for (let i = resolved.length - 1; i >= 0; i--) {
49
+ const { raw, key, resolvedOverrides } = resolved[i];
50
+ const isBase = resolvedOverrides === null;
51
+ const sourceOverride = isBase && sourceOverrides ? sourceOverrides[raw.name] : undefined;
52
+ let replacement;
53
+ if (sourceOverride) {
54
+ replacement = escapeSqlIdentifier(sourceOverride.relationName);
55
+ }
56
+ else {
57
+ const fileName = fileNameMap.get(key) ?? `${raw.name}.parquet`;
58
+ replacement = `'${fileName}'`;
59
+ }
60
+ processedSql =
61
+ processedSql.slice(0, raw.start) +
62
+ replacement +
63
+ processedSql.slice(raw.end);
64
+ }
65
+ processedSql = appendLimitIfMissing(processedSql, maxRows);
66
+ return substituteSqlParams(processedSql, sqlParamValues);
67
+ }
68
+ /**
69
+ * Preprocess SQL: resolve placeholders, register UDFs via the bridge,
70
+ * substitute params, sign URLs, append LIMIT. Returns the prepared SQL.
71
+ */
72
+ export function useDuckDbSqlQueryPreprocessing({ sql, enabled = true, maxRows = DEFAULT_MAX_ROWS, sourceOverrides, }) {
73
+ const bridge = useFusedWidgetBridge();
74
+ const { startLoading: startEdgeLoading, stopLoading: stopEdgeLoading } = useJsonUiEdgeAnimation();
75
+ const { log } = useJsonUiLog();
76
+ const [processedSql, setProcessedSql] = useState("");
77
+ const [loading, setLoading] = useState(false);
78
+ const [error, setError] = useState(null);
79
+ const [fetchKey, setFetchKey] = useState(0);
80
+ const sourcePlaceholders = useMemo(() => {
81
+ if (!sql)
82
+ return [];
83
+ return parseSqlUdfPlaceholders(sql);
84
+ }, [sql]);
85
+ // Skip placeholders that have a sourceOverride active (workbench-only path).
86
+ const placeholdersAfterOverride = useMemo(() => {
87
+ if (!sourceOverrides)
88
+ return sourcePlaceholders;
89
+ return sourcePlaceholders.filter((p) => p.overrides !== null || !sourceOverrides[p.name]);
90
+ }, [sourcePlaceholders, sourceOverrides]);
91
+ // Override values may reference $params — collect those names so we can
92
+ // subscribe to the same canvas/form param map the SQL body uses.
93
+ const overrideParamNames = useMemo(() => {
94
+ const set = new Set();
95
+ for (const p of placeholdersAfterOverride) {
96
+ if (!p.overrides)
97
+ continue;
98
+ for (const v of Object.values(p.overrides)) {
99
+ const name = getDollarRefName(v);
100
+ if (name)
101
+ set.add(name);
102
+ }
103
+ }
104
+ return Array.from(set);
105
+ }, [placeholdersAfterOverride]);
106
+ const sqlParamNames = useMemo(() => {
107
+ if (!sql)
108
+ return [];
109
+ return extractSqlParams(sql);
110
+ }, [sql]);
111
+ // Subscribe to canvas + form params for both the SQL body and override refs.
112
+ const allParamNames = useMemo(() => {
113
+ const seen = new Set();
114
+ const out = [];
115
+ for (const n of sqlParamNames) {
116
+ if (seen.has(n))
117
+ continue;
118
+ seen.add(n);
119
+ out.push(n);
120
+ }
121
+ for (const n of overrideParamNames) {
122
+ if (seen.has(n))
123
+ continue;
124
+ seen.add(n);
125
+ out.push(n);
126
+ }
127
+ return out;
128
+ }, [sqlParamNames, overrideParamNames]);
129
+ const canvasParamValues = useCanvasParams(allParamNames);
130
+ const { inForm, values: formParamValues } = useFormParams(allParamNames);
131
+ const sqlParamValues = useMemo(() => inForm ? { ...canvasParamValues, ...formParamValues } : canvasParamValues, [inForm, canvasParamValues, formParamValues]);
132
+ const resolvedPlaceholders = useMemo(() => {
133
+ return placeholdersAfterOverride.map((p) => {
134
+ if (!p.overrides) {
135
+ return {
136
+ raw: p,
137
+ key: p.name,
138
+ resolvedOverrides: null,
139
+ unresolved: false,
140
+ };
141
+ }
142
+ const resolvedOverrides = {};
143
+ let unresolved = false;
144
+ for (const [paramKey, rawValue] of Object.entries(p.overrides)) {
145
+ const r = resolveOverrideValue(rawValue, sqlParamValues);
146
+ if (r.unresolved)
147
+ unresolved = true;
148
+ resolvedOverrides[paramKey] = r.value;
149
+ }
150
+ return {
151
+ raw: p,
152
+ key: computePlaceholderKey(p.name, resolvedOverrides),
153
+ resolvedOverrides,
154
+ unresolved,
155
+ };
156
+ });
157
+ }, [placeholdersAfterOverride, sqlParamValues]);
158
+ // Deduplicate refs we need to pass to the bridge for VFS registration.
159
+ const vfsRefs = useMemo(() => {
160
+ const seen = new Set();
161
+ const out = [];
162
+ for (const rp of resolvedPlaceholders) {
163
+ if (rp.unresolved)
164
+ continue;
165
+ if (seen.has(rp.key))
166
+ continue;
167
+ seen.add(rp.key);
168
+ out.push({
169
+ name: rp.raw.name,
170
+ key: rp.key,
171
+ overrides: rp.resolvedOverrides ?? undefined,
172
+ });
173
+ }
174
+ return out;
175
+ }, [resolvedPlaceholders]);
176
+ // Stable key for the refs list — avoids re-running the resolve effect when
177
+ // the underlying refs are structurally identical.
178
+ const refsKey = useMemo(() => {
179
+ return vfsRefs
180
+ .map((r) => `${r.key}|${r.name}|${r.overrides
181
+ ? Object.entries(r.overrides)
182
+ .sort(([a], [b]) => a.localeCompare(b))
183
+ .map(([k, v]) => `${k}=${v}`)
184
+ .join(",")
185
+ : ""}`)
186
+ .join("\n");
187
+ }, [vfsRefs]);
188
+ const refetch = useCallback(() => {
189
+ setFetchKey((k) => k + 1);
190
+ }, []);
191
+ // Subscribe to UDF output changes for every referenced UDF — `resolveVfsFilenames`
192
+ // doesn't auto-rerun when a UDF re-executes, so we trigger a refetch.
193
+ useEffect(() => {
194
+ if (!enabled || vfsRefs.length === 0)
195
+ return;
196
+ const unsubs = vfsRefs.map((ref) => bridge.udfs.subscribeOutput(ref.name, () => {
197
+ setFetchKey((k) => k + 1);
198
+ }));
199
+ return () => {
200
+ unsubs.forEach((u) => u());
201
+ };
202
+ }, [bridge, enabled, vfsRefs]);
203
+ // Edge animation mirrors loading.
204
+ useEffect(() => {
205
+ if (loading)
206
+ startEdgeLoading();
207
+ else
208
+ stopEdgeLoading();
209
+ }, [loading, startEdgeLoading, stopEdgeLoading]);
210
+ // Watch for sourceOverride errors / loading so the preprocessing reflects them.
211
+ const sourceOverrideError = useMemo(() => {
212
+ if (!sourceOverrides)
213
+ return null;
214
+ for (const p of sourcePlaceholders) {
215
+ if (p.overrides !== null)
216
+ continue;
217
+ const src = sourceOverrides[p.name];
218
+ if (src?.error)
219
+ return src.error;
220
+ }
221
+ return null;
222
+ }, [sourcePlaceholders, sourceOverrides]);
223
+ const sourceOverrideLoading = useMemo(() => {
224
+ if (!sourceOverrides)
225
+ return false;
226
+ return sourcePlaceholders.some((p) => p.overrides === null && sourceOverrides[p.name]?.loading);
227
+ }, [sourcePlaceholders, sourceOverrides]);
228
+ const hasUnresolvedOverride = resolvedPlaceholders.some((rp) => rp.unresolved);
229
+ useEffect(() => {
230
+ if (!enabled || !sql) {
231
+ setProcessedSql("");
232
+ setLoading(false);
233
+ setError(null);
234
+ return;
235
+ }
236
+ if (sourceOverrideError) {
237
+ setProcessedSql("");
238
+ setError(sourceOverrideError);
239
+ setLoading(false);
240
+ log(`SQL preprocessing: ${sourceOverrideError}`, "error");
241
+ return;
242
+ }
243
+ if (sourceOverrideLoading || hasUnresolvedOverride) {
244
+ setProcessedSql("");
245
+ setError(null);
246
+ setLoading(true);
247
+ return;
248
+ }
249
+ let cancelled = false;
250
+ setLoading(true);
251
+ setError(null);
252
+ (async () => {
253
+ let fileNameMap = new Map();
254
+ let registrationErrorMap;
255
+ if (vfsRefs.length > 0) {
256
+ try {
257
+ const result = await bridge.sql.resolveVfsFilenames(vfsRefs);
258
+ if (cancelled)
259
+ return;
260
+ if (result instanceof Map) {
261
+ // Legacy shape — names → filenames. Build a key-keyed map.
262
+ for (const rp of resolvedPlaceholders) {
263
+ if (rp.resolvedOverrides)
264
+ continue; // legacy hosts can't handle overrides
265
+ const fn = result.get(rp.raw.name);
266
+ if (fn)
267
+ fileNameMap.set(rp.key, fn);
268
+ }
269
+ }
270
+ else {
271
+ fileNameMap = result.filenames;
272
+ registrationErrorMap = result.errors;
273
+ }
274
+ }
275
+ catch (e) {
276
+ if (cancelled)
277
+ return;
278
+ const msg = e instanceof Error
279
+ ? e.message
280
+ : typeof e === "string"
281
+ ? e
282
+ : "VFS registration failed";
283
+ setProcessedSql("");
284
+ setError(msg);
285
+ setLoading(false);
286
+ log(`SQL preprocessing: ${msg}`, "error");
287
+ return;
288
+ }
289
+ }
290
+ // Surface any per-key registration error.
291
+ if (registrationErrorMap) {
292
+ for (const rp of resolvedPlaceholders) {
293
+ const err = registrationErrorMap.get(rp.key);
294
+ if (err) {
295
+ if (cancelled)
296
+ return;
297
+ setProcessedSql("");
298
+ setError(err);
299
+ setLoading(false);
300
+ log(`SQL preprocessing: ${err}`, "error");
301
+ return;
302
+ }
303
+ }
304
+ }
305
+ // Every non-unresolved placeholder should now have a filename (or use a sourceOverride).
306
+ for (const rp of resolvedPlaceholders) {
307
+ if (rp.unresolved)
308
+ continue;
309
+ const isBase = rp.resolvedOverrides === null;
310
+ const hasSourceOverride = isBase && sourceOverrides?.[rp.raw.name] !== undefined;
311
+ if (hasSourceOverride)
312
+ continue;
313
+ if (!fileNameMap.has(rp.key)) {
314
+ if (cancelled)
315
+ return;
316
+ // Bridge didn't return a filename; treat as still loading.
317
+ setProcessedSql("");
318
+ setError(null);
319
+ setLoading(true);
320
+ return;
321
+ }
322
+ }
323
+ let nextProcessedSql;
324
+ try {
325
+ nextProcessedSql = buildProcessedSql(sql, resolvedPlaceholders, fileNameMap, sourceOverrides, sqlParamValues, maxRows);
326
+ }
327
+ catch (e) {
328
+ if (cancelled)
329
+ return;
330
+ const msg = e instanceof Error
331
+ ? e.message
332
+ : typeof e === "string"
333
+ ? e
334
+ : "SQL preprocessing failed";
335
+ setProcessedSql("");
336
+ setError(msg);
337
+ setLoading(false);
338
+ log(`SQL preprocessing failed: ${msg}`, "error");
339
+ return;
340
+ }
341
+ const urls = extractSignableUrls(nextProcessedSql);
342
+ if (urls.length === 0) {
343
+ if (cancelled)
344
+ return;
345
+ setProcessedSql(nextProcessedSql);
346
+ setError(null);
347
+ setLoading(false);
348
+ log("SQL preprocessing completed");
349
+ return;
350
+ }
351
+ try {
352
+ const signedMap = {};
353
+ const signed = await Promise.all(urls.map((u) => bridge.signUrl(u)));
354
+ if (cancelled)
355
+ return;
356
+ urls.forEach((u, i) => {
357
+ signedMap[u] = signed[i].signed;
358
+ });
359
+ const signedSql = rewriteSignedUrls(nextProcessedSql, signedMap);
360
+ setProcessedSql(signedSql);
361
+ setError(null);
362
+ setLoading(false);
363
+ log("SQL preprocessing completed");
364
+ }
365
+ catch (e) {
366
+ if (cancelled)
367
+ return;
368
+ const msg = e instanceof Error
369
+ ? e.message
370
+ : typeof e === "string"
371
+ ? e
372
+ : "URL signing failed";
373
+ setProcessedSql("");
374
+ setError(msg);
375
+ setLoading(false);
376
+ log(`SQL preprocessing failed: ${msg}`, "error");
377
+ }
378
+ })();
379
+ return () => {
380
+ cancelled = true;
381
+ };
382
+ }, [
383
+ bridge,
384
+ enabled,
385
+ sql,
386
+ refsKey,
387
+ resolvedPlaceholders,
388
+ sqlParamValues,
389
+ sourceOverrides,
390
+ sourceOverrideError,
391
+ sourceOverrideLoading,
392
+ hasUnresolvedOverride,
393
+ maxRows,
394
+ fetchKey,
395
+ log,
396
+ vfsRefs,
397
+ ]);
398
+ return { processedSql, loading, error, refetch };
399
+ }
400
+ /**
401
+ * Execute a DuckDB SQL query against UDF Parquet outputs. Uses
402
+ * `useDuckDbSqlQueryPreprocessing` to prepare the SQL string, then runs
403
+ * it via the bridge.
404
+ */
405
+ export function useDuckDbSqlQuery({ sql, enabled = true, maxRows = DEFAULT_MAX_ROWS, sourceOverrides, }) {
406
+ const bridge = useFusedWidgetBridge();
407
+ const { startLoading: startEdgeLoading, stopLoading: stopEdgeLoading } = useJsonUiEdgeAnimation();
408
+ const { log } = useJsonUiLog();
409
+ const [rows, setRows] = useState(EMPTY_ROWS);
410
+ const [columns, setColumns] = useState(EMPTY_COLUMNS);
411
+ const [queryLoading, setQueryLoading] = useState(false);
412
+ const [error, setError] = useState(null);
413
+ const [fetchKey, setFetchKey] = useState(0);
414
+ const { processedSql, loading: preprocessingLoading, error: preprocessingError, refetch: refetchPreprocessing, } = useDuckDbSqlQueryPreprocessing({
415
+ sql,
416
+ enabled,
417
+ maxRows,
418
+ sourceOverrides,
419
+ });
420
+ // Bridges the single-frame gap between preprocessing completing and the
421
+ // query effect starting — keeps `loading` true through that frame.
422
+ const consumedSqlRef = useRef("");
423
+ const awaitingExecution = enabled &&
424
+ !!sql &&
425
+ !!processedSql &&
426
+ !preprocessingLoading &&
427
+ !preprocessingError &&
428
+ processedSql !== consumedSqlRef.current;
429
+ const loading = preprocessingLoading || queryLoading || awaitingExecution;
430
+ useEffect(() => {
431
+ if (loading)
432
+ startEdgeLoading();
433
+ else
434
+ stopEdgeLoading();
435
+ }, [loading, startEdgeLoading, stopEdgeLoading]);
436
+ const refetch = useCallback(() => {
437
+ consumedSqlRef.current = "";
438
+ refetchPreprocessing();
439
+ setFetchKey((k) => k + 1);
440
+ }, [refetchPreprocessing]);
441
+ useEffect(() => {
442
+ if (!enabled || !sql) {
443
+ consumedSqlRef.current = "";
444
+ setRows(EMPTY_ROWS);
445
+ setColumns(EMPTY_COLUMNS);
446
+ setQueryLoading(false);
447
+ setError(null);
448
+ return;
449
+ }
450
+ if (preprocessingError) {
451
+ consumedSqlRef.current = "";
452
+ setError(preprocessingError);
453
+ setRows(EMPTY_ROWS);
454
+ setColumns(EMPTY_COLUMNS);
455
+ setQueryLoading(false);
456
+ return;
457
+ }
458
+ if (preprocessingLoading || !processedSql) {
459
+ consumedSqlRef.current = "";
460
+ setQueryLoading(false);
461
+ return;
462
+ }
463
+ let cancelled = false;
464
+ const controller = new AbortController();
465
+ consumedSqlRef.current = processedSql;
466
+ setQueryLoading(true);
467
+ setError(null);
468
+ const truncatedSql = processedSql.length > 120
469
+ ? processedSql.slice(0, 120) + "…"
470
+ : processedSql;
471
+ log(`SQL query started: ${truncatedSql}`);
472
+ const t0 = performance.now();
473
+ bridge.sql.query(processedSql, { signal: controller.signal }).then((result) => {
474
+ if (cancelled)
475
+ return;
476
+ const elapsed = Math.round(performance.now() - t0);
477
+ if (result.error) {
478
+ setRows(EMPTY_ROWS);
479
+ setColumns(EMPTY_COLUMNS);
480
+ setError(result.error);
481
+ setQueryLoading(false);
482
+ log(`SQL failed (${elapsed}ms): ${result.error}`, "error");
483
+ return;
484
+ }
485
+ setRows(result.rows.length === 0 ? EMPTY_ROWS : result.rows);
486
+ setColumns(result.columns);
487
+ setError(null);
488
+ setQueryLoading(false);
489
+ log(`SQL completed: ${result.rows.length} row${result.rows.length !== 1 ? "s" : ""} in ${elapsed}ms`);
490
+ }, (e) => {
491
+ if (cancelled)
492
+ return;
493
+ if (e?.name === "AbortError")
494
+ return;
495
+ const elapsed = Math.round(performance.now() - t0);
496
+ const msg = e instanceof Error
497
+ ? e.message
498
+ : typeof e === "string"
499
+ ? e
500
+ : "SQL query failed";
501
+ setError(msg);
502
+ setRows(EMPTY_ROWS);
503
+ setColumns(EMPTY_COLUMNS);
504
+ setQueryLoading(false);
505
+ log(`SQL failed (${elapsed}ms): ${msg}`, "error");
506
+ });
507
+ return () => {
508
+ cancelled = true;
509
+ controller.abort();
510
+ };
511
+ }, [
512
+ bridge,
513
+ enabled,
514
+ sql,
515
+ processedSql,
516
+ preprocessingLoading,
517
+ preprocessingError,
518
+ fetchKey,
519
+ log,
520
+ ]);
521
+ return { rows, columns, loading, error, refetch };
522
+ }
523
+ /**
524
+ * Resolve UDF names to VFS filenames, registering them in DuckDB if needed.
525
+ * Exposed for advanced use cases (e.g. building your own query string).
526
+ */
527
+ export function useVfsRegistration(udfNames, enabled = true) {
528
+ const bridge = useFusedWidgetBridge();
529
+ const [state, setState] = useState({ filenames: new Map(), loading: false });
530
+ const namesKey = useMemo(() => udfNames.slice().sort().join("|"), [udfNames]);
531
+ useEffect(() => {
532
+ if (!enabled || udfNames.length === 0) {
533
+ setState({ filenames: new Map(), loading: false });
534
+ return;
535
+ }
536
+ let cancelled = false;
537
+ setState((prev) => ({ ...prev, loading: true, error: undefined }));
538
+ bridge.sql.resolveVfsFilenames(udfNames).then((result) => {
539
+ if (cancelled)
540
+ return;
541
+ const filenames = result instanceof Map ? result : result.filenames;
542
+ setState({ filenames, loading: false });
543
+ }, (err) => {
544
+ if (cancelled)
545
+ return;
546
+ setState({
547
+ filenames: new Map(),
548
+ loading: false,
549
+ error: err instanceof Error ? err.message : String(err),
550
+ });
551
+ });
552
+ return () => {
553
+ cancelled = true;
554
+ };
555
+ // eslint-disable-next-line react-hooks/exhaustive-deps
556
+ }, [bridge, namesKey, enabled]);
557
+ return state;
558
+ }
@@ -0,0 +1,40 @@
1
+ import type { UseFusedParamOptions, UseFusedParamReturn } from "../types";
2
+ /**
3
+ * Two-way bind a component to a named canvas parameter.
4
+ *
5
+ * When another node broadcasts a param value (e.g. a map click, a dropdown
6
+ * selection from upstream), this hook receives it and updates `value`. When
7
+ * the user interacts locally, `setValue()` debounces and broadcasts back so
8
+ * connected UDF nodes re-run with the new value.
9
+ *
10
+ * Form integration: when this component is rendered inside a built-in Form,
11
+ * the live form-field value shadows the canvas value so siblings react
12
+ * before the form is submitted (form values are never broadcast to the
13
+ * canvas until submit).
14
+ *
15
+ * Works as plain local state if `param` is undefined or empty.
16
+ *
17
+ * Requires: a `FusedWidgetBridgeContext.Provider` ancestor. The workbench's
18
+ * `JsonUiProvider` provides one; tests use `createTestBridge()`.
19
+ *
20
+ * @example — number counter
21
+ * const { value, setValue } = useFusedParam({ param: "count", defaultValue: 0 });
22
+ * return <button onClick={() => setValue(value + 1)}>{value}</button>;
23
+ *
24
+ * @example — typed array param
25
+ * const { value, setValue } = useFusedParam({
26
+ * param: "bounds",
27
+ * defaultValue: [-74, 40, -73, 41] as [number, number, number, number],
28
+ * validate: (v): v is [number, number, number, number] =>
29
+ * Array.isArray(v) && v.length === 4 && v.every(n => typeof n === "number"),
30
+ * });
31
+ *
32
+ * @example — broadcast immediately on mouseup (bypass debounce)
33
+ * const { setValue, broadcastNow } = useFusedParam({ param: "hue", defaultValue: 0 });
34
+ * <input type="range" onChange={e => setValue(+e.target.value)} onMouseUp={broadcastNow} />
35
+ *
36
+ * @example — clear button
37
+ * const { clearValue } = useFusedParam({ param: "selection", defaultValue: null });
38
+ * <button onClick={() => clearValue(null)}>Reset</button>
39
+ */
40
+ export declare function useFusedParam<T>({ param, debounceMs, readOnly, defaultValue, broadcastDefaultValue, validate, preprocess, }: UseFusedParamOptions<T>): UseFusedParamReturn<T>;