@walkthru-earth/objex 1.1.0 → 1.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.
- package/README.md +3 -1
- package/dist/components/browser/FileBrowser.svelte +25 -14
- package/dist/components/browser/FileTreeSidebar.svelte +43 -7
- package/dist/components/layout/ConnectionDialog.svelte +100 -1
- package/dist/components/layout/Sidebar.svelte +70 -25
- package/dist/components/viewers/ArchiveViewer.svelte +4 -4
- package/dist/components/viewers/CodeViewer.svelte +44 -5
- package/dist/components/viewers/CogControls.svelte +208 -0
- package/dist/components/viewers/CogControls.svelte.d.ts +12 -0
- package/dist/components/viewers/CogViewer.svelte +373 -1162
- package/dist/components/viewers/CogViewer.svelte.d.ts +1 -1
- package/dist/components/viewers/CopcViewer.svelte +20 -2
- package/dist/components/viewers/DatabaseViewer.svelte +345 -37
- package/dist/components/viewers/FlatGeobufViewer.svelte +15 -9
- package/dist/components/viewers/MarkdownViewer.svelte +1 -1
- package/dist/components/viewers/PmtilesViewer.svelte +2 -2
- package/dist/components/viewers/StacMapViewer.svelte +25 -9
- package/dist/components/viewers/TableViewer.svelte +162 -51
- package/dist/components/viewers/ZarrMapViewer.svelte +33 -4
- package/dist/components/viewers/ZarrViewer.svelte +3 -6
- package/dist/components/viewers/pmtiles/PmtilesMapView.svelte +0 -1
- package/dist/constants.d.ts +6 -2
- package/dist/constants.js +6 -2
- package/dist/file-icons/index.d.ts +1 -1
- package/dist/file-icons/index.js +12 -2
- package/dist/i18n/ar.js +25 -0
- package/dist/i18n/en.js +25 -0
- package/dist/i18n/index.svelte.d.ts +0 -1
- package/dist/i18n/index.svelte.js +0 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/query/engine.d.ts +20 -4
- package/dist/query/index.d.ts +2 -1
- package/dist/query/index.js +1 -0
- package/dist/query/source.d.ts +42 -0
- package/dist/query/source.js +54 -0
- package/dist/query/wasm.d.ts +7 -5
- package/dist/query/wasm.js +267 -107
- package/dist/storage/adapter.d.ts +9 -0
- package/dist/storage/adapter.js +13 -1
- package/dist/storage/browser-azure.d.ts +1 -1
- package/dist/storage/browser-azure.js +4 -0
- package/dist/storage/browser-cloud.d.ts +1 -1
- package/dist/storage/browser-cloud.js +7 -0
- package/dist/storage/presign.d.ts +13 -0
- package/dist/storage/presign.js +55 -0
- package/dist/storage/providers.d.ts +53 -0
- package/dist/storage/providers.js +171 -0
- package/dist/stores/browser.svelte.d.ts +2 -0
- package/dist/stores/browser.svelte.js +17 -1
- package/dist/stores/files.svelte.d.ts +1 -2
- package/dist/stores/files.svelte.js +1 -2
- package/dist/stores/tabs.svelte.d.ts +9 -2
- package/dist/stores/tabs.svelte.js +11 -2
- package/dist/types.d.ts +11 -0
- package/dist/utils/cog.d.ts +244 -0
- package/dist/utils/cog.js +1039 -0
- package/dist/utils/deck.d.ts +0 -18
- package/dist/utils/deck.js +0 -36
- package/dist/utils/geometry-type.d.ts +52 -0
- package/dist/utils/geometry-type.js +76 -0
- package/dist/utils/markdown-sql.d.ts +1 -1
- package/dist/utils/markdown-sql.js +3 -4
- package/dist/utils/pmtiles-tile.d.ts +0 -2
- package/dist/utils/pmtiles-tile.js +0 -8
- package/dist/utils/url-state.d.ts +6 -0
- package/dist/utils/url-state.js +34 -26
- package/dist/utils/url.d.ts +26 -9
- package/dist/utils/url.js +52 -25
- package/dist/utils/wkb.js +22 -8
- package/dist/utils/zarr-tab.d.ts +22 -0
- package/dist/utils/zarr-tab.js +30 -0
- package/dist/utils/zarr.d.ts +0 -2
- package/dist/utils/zarr.js +73 -44
- package/package.json +47 -43
- package/dist/components/ui/tabs/index.d.ts +0 -5
- package/dist/components/ui/tabs/index.js +0 -7
- package/dist/components/ui/tabs/tabs-content.svelte +0 -17
- package/dist/components/ui/tabs/tabs-content.svelte.d.ts +0 -4
- package/dist/components/ui/tabs/tabs-list.svelte +0 -16
- package/dist/components/ui/tabs/tabs-list.svelte.d.ts +0 -4
- package/dist/components/ui/tabs/tabs-trigger.svelte +0 -20
- package/dist/components/ui/tabs/tabs-trigger.svelte.d.ts +0 -4
- package/dist/components/ui/tabs/tabs.svelte +0 -19
- package/dist/components/ui/tabs/tabs.svelte.d.ts +0 -4
- package/dist/components/viewers/MapViewer.svelte +0 -234
- package/dist/components/viewers/MapViewer.svelte.d.ts +0 -7
- package/dist/components/viewers/StyleEditorOverlay.svelte +0 -27
- package/dist/components/viewers/StyleEditorOverlay.svelte.d.ts +0 -7
package/dist/query/wasm.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { DEFAULT_TARGET_CRS, DUCKDB_INIT_TIMEOUT_MS, WGS84_CODES } from '../constants.js';
|
|
2
|
-
import {
|
|
2
|
+
import { getAccessMode, resolveProviderEndpoint } from '../storage/providers.js';
|
|
3
3
|
import { credentialStore } from '../stores/credentials.svelte.js';
|
|
4
|
+
import { buildTransformExpr, wrapWkbWithCrs } from '../utils/geometry-type.js';
|
|
4
5
|
import { QueryCancelledError } from './engine';
|
|
6
|
+
import { isHttpsSourceRef } from './source.js';
|
|
5
7
|
const DUCKDB_VERSION = __DUCKDB_WASM_VERSION__;
|
|
6
8
|
const CDN_BASE = `https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@${DUCKDB_VERSION}/dist`;
|
|
7
9
|
const duckdb_wasm = `${CDN_BASE}/duckdb-mvp.wasm`;
|
|
@@ -66,21 +68,29 @@ async function getDB() {
|
|
|
66
68
|
const conn = await db.connect();
|
|
67
69
|
try {
|
|
68
70
|
const tExt = performance.now();
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// "
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
//
|
|
71
|
+
// Workaround for duckdb/duckdb-wasm#2199: calling duckdb_coordinate_systems()
|
|
72
|
+
// FIRST autoloads spatial and registers default CRS properly. Running it
|
|
73
|
+
// AFTER an explicit LOAD spatial triggers a timing bug in PROJ (WASM-only)
|
|
74
|
+
// that causes "stoi: no conversion" on any GeoParquet with CRS metadata.
|
|
75
|
+
await withTimeout(conn.query('SELECT * FROM duckdb_coordinate_systems(); INSTALL httpfs; LOAD httpfs; INSTALL spatial; LOAD spatial;'), INIT_TIMEOUT_MS, 'extension install (httpfs + spatial)');
|
|
76
|
+
// DuckDB v1.5: GEOMETRY is a core type with optional CRS parameter.
|
|
77
|
+
// SET geometry_always_xy = true forces lon/lat (x/y) axis order globally,
|
|
78
|
+
// matching GeoJSON/GeoParquet convention. Without this, DuckDB v1.5 emits
|
|
79
|
+
// warnings on ST_Transform and other coordinate-sensitive functions.
|
|
76
80
|
// SET GLOBAL applies to all future connections (no per-connection overhead).
|
|
77
81
|
try {
|
|
78
|
-
await conn.query('SET GLOBAL
|
|
79
|
-
geoConversionGlobal = true;
|
|
82
|
+
await conn.query('SET GLOBAL geometry_always_xy = true');
|
|
80
83
|
}
|
|
81
84
|
catch {
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
logWarn('geometry_always_xy not available — ST_Transform calls may warn');
|
|
86
|
+
}
|
|
87
|
+
// Raise httpfs force_download threshold so moderately sized remote files
|
|
88
|
+
// are fetched in one shot instead of many small range requests.
|
|
89
|
+
try {
|
|
90
|
+
await conn.query('SET GLOBAL force_download_threshold = 2000000');
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
logWarn('force_download_threshold not available');
|
|
84
94
|
}
|
|
85
95
|
log(`getDB → extensions loaded in ${elapsed(tExt)}`);
|
|
86
96
|
}
|
|
@@ -96,19 +106,41 @@ async function getDB() {
|
|
|
96
106
|
});
|
|
97
107
|
return dbPromise;
|
|
98
108
|
}
|
|
99
|
-
|
|
109
|
+
// ─── Geometry type helpers ────────────────────────────────────────────
|
|
100
110
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* Otherwise falls back to per-connection SET.
|
|
111
|
+
* Check if a DuckDB column type string is a spatial type that ST_AsWKB accepts directly.
|
|
112
|
+
* Handles DuckDB v1.5 parameterized types like GEOMETRY('EPSG:4326').
|
|
104
113
|
*/
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
function isSpatialColumnType(typeUpper) {
|
|
115
|
+
return (typeUpper.startsWith('GEOMETRY') ||
|
|
116
|
+
typeUpper.startsWith('GEOGRAPHY') ||
|
|
117
|
+
typeUpper === 'WKB_BLOB' ||
|
|
118
|
+
typeUpper.includes('POINT') ||
|
|
119
|
+
typeUpper.includes('LINESTRING') ||
|
|
120
|
+
typeUpper.includes('POLYGON') ||
|
|
121
|
+
typeUpper.includes('BINARY') // Arrow serialization of DuckDB GEOMETRY
|
|
122
|
+
);
|
|
109
123
|
}
|
|
110
124
|
// ─── CRS detection helpers ───────────────────────────────────────────
|
|
111
125
|
// WGS84_CODES imported from constants.ts
|
|
126
|
+
/**
|
|
127
|
+
* Extract CRS from DuckDB v1.5 parameterized GEOMETRY type string.
|
|
128
|
+
* e.g., "GEOMETRY('EPSG:4326')" → { found: true, crs: null } (WGS84)
|
|
129
|
+
* e.g., "GEOMETRY('EPSG:27700')" → { found: true, crs: 'EPSG:27700' }
|
|
130
|
+
* e.g., "GEOMETRY" → { found: false, crs: null } (no CRS in type)
|
|
131
|
+
*/
|
|
132
|
+
function extractCrsFromTypeString(typeStr) {
|
|
133
|
+
const match = typeStr.match(/^GEOMETRY\('([^']+)'\)/i);
|
|
134
|
+
if (!match)
|
|
135
|
+
return { found: false, crs: null };
|
|
136
|
+
const crs = match[1];
|
|
137
|
+
if (crs === 'EPSG:4326' || crs === 'OGC:CRS84')
|
|
138
|
+
return { found: true, crs: null };
|
|
139
|
+
const epsgMatch = crs.match(/^EPSG:(\d+)$/);
|
|
140
|
+
if (epsgMatch && WGS84_CODES.has(Number(epsgMatch[1])))
|
|
141
|
+
return { found: true, crs: null };
|
|
142
|
+
return { found: true, crs };
|
|
143
|
+
}
|
|
112
144
|
/** Extract EPSG code from a PROJJSON object. Returns null for WGS84/CRS84. */
|
|
113
145
|
function extractEpsgFromProjjson(crs) {
|
|
114
146
|
if (!crs)
|
|
@@ -203,13 +235,18 @@ async function extractCrsFromLogicalType(logicalType, conn, path) {
|
|
|
203
235
|
// DuckDB Arrow type strings that represent binary/blob data — not useful
|
|
204
236
|
// for map tooltips and expensive to extract row-by-row.
|
|
205
237
|
const BINARY_TYPES = new Set(['BLOB', 'BYTEA', 'BINARY', 'LARGEBINARY', 'WKB_BLOB']);
|
|
206
|
-
/**
|
|
238
|
+
/**
|
|
239
|
+
* True if the Arrow type string represents a numeric primitive whose `.toArray()`
|
|
240
|
+
* returns a plain typed array (zero-copy fast path). DECIMAL is excluded: Arrow
|
|
241
|
+
* emits decimals as multi-word BigInt buffers that need scale-aware formatting.
|
|
242
|
+
*/
|
|
207
243
|
function isNumericArrowType(typeStr) {
|
|
208
244
|
const t = typeStr.toUpperCase();
|
|
245
|
+
if (t.startsWith('DECIMAL'))
|
|
246
|
+
return false;
|
|
209
247
|
return (t.includes('INT') ||
|
|
210
248
|
t.includes('FLOAT') ||
|
|
211
249
|
t.includes('DOUBLE') ||
|
|
212
|
-
t.includes('DECIMAL') ||
|
|
213
250
|
t === 'TINYINT' ||
|
|
214
251
|
t === 'SMALLINT' ||
|
|
215
252
|
t === 'BIGINT' ||
|
|
@@ -219,6 +256,53 @@ function isNumericArrowType(typeStr) {
|
|
|
219
256
|
t === 'USMALLINT' ||
|
|
220
257
|
t === 'UTINYINT');
|
|
221
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Parse DuckDB/Arrow DECIMAL type string → scale. Returns -1 if not a decimal.
|
|
261
|
+
*
|
|
262
|
+
* DuckDB DESCRIBE emits `DECIMAL(10,2)`. Arrow's `Decimal.toString()` emits
|
|
263
|
+
* `Decimal[10e+2]` (precision `e` signed-scale). Accept both.
|
|
264
|
+
*/
|
|
265
|
+
function decimalScale(typeStr) {
|
|
266
|
+
const m = /^(?:DECIMAL\(\s*\d+\s*,\s*(-?\d+)\s*\)|Decimal\[\s*\d+e([+-]?\d+)\s*\])/i.exec(typeStr);
|
|
267
|
+
if (!m)
|
|
268
|
+
return -1;
|
|
269
|
+
return Number(m[1] ?? m[2]);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Format an Arrow Decimal value (BigInt or Uint32Array of little-endian words)
|
|
273
|
+
* into a human-readable decimal string, applying the column scale.
|
|
274
|
+
*/
|
|
275
|
+
function formatDecimal(raw, scale) {
|
|
276
|
+
if (raw == null)
|
|
277
|
+
return null;
|
|
278
|
+
let bn;
|
|
279
|
+
if (typeof raw === 'bigint') {
|
|
280
|
+
bn = raw;
|
|
281
|
+
}
|
|
282
|
+
else if (raw instanceof Uint32Array || raw instanceof Int32Array) {
|
|
283
|
+
const hi = BigInt(raw[raw.length - 1] >>> 0);
|
|
284
|
+
const signed = hi >= 0x80000000n;
|
|
285
|
+
let acc = 0n;
|
|
286
|
+
for (let w = raw.length - 1; w >= 0; w--) {
|
|
287
|
+
acc = (acc << 32n) | BigInt(raw[w] >>> 0);
|
|
288
|
+
}
|
|
289
|
+
bn = signed ? acc - (1n << BigInt(raw.length * 32)) : acc;
|
|
290
|
+
}
|
|
291
|
+
else if (typeof raw === 'number') {
|
|
292
|
+
bn = BigInt(raw);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
return String(raw);
|
|
296
|
+
}
|
|
297
|
+
const neg = bn < 0n;
|
|
298
|
+
const abs = neg ? -bn : bn;
|
|
299
|
+
if (scale <= 0)
|
|
300
|
+
return (neg ? '-' : '') + abs.toString();
|
|
301
|
+
const divisor = 10n ** BigInt(scale);
|
|
302
|
+
const intPart = abs / divisor;
|
|
303
|
+
const fracPart = (abs % divisor).toString().padStart(scale, '0');
|
|
304
|
+
return `${neg ? '-' : ''}${intPart}.${fracPart}`;
|
|
305
|
+
}
|
|
222
306
|
/**
|
|
223
307
|
* Extract column values using the fastest available method:
|
|
224
308
|
* - Numeric primitives → .toArray() returns a typed array view (zero-copy),
|
|
@@ -226,6 +310,14 @@ function isNumericArrowType(typeStr) {
|
|
|
226
310
|
* - Other types → per-element .get(i) for correctness (strings, structs, etc.)
|
|
227
311
|
*/
|
|
228
312
|
function extractColumnBulk(col, numRows, typeStr) {
|
|
313
|
+
const scale = decimalScale(typeStr);
|
|
314
|
+
if (scale >= 0) {
|
|
315
|
+
const values = new Array(numRows);
|
|
316
|
+
for (let i = 0; i < numRows; i++) {
|
|
317
|
+
values[i] = formatDecimal(col.get(i), scale);
|
|
318
|
+
}
|
|
319
|
+
return values;
|
|
320
|
+
}
|
|
229
321
|
if (isNumericArrowType(typeStr)) {
|
|
230
322
|
// .toArray() returns a TypedArray (Float64Array, Int32Array, etc.)
|
|
231
323
|
// which is a zero-copy view over the Arrow buffer.
|
|
@@ -242,6 +334,13 @@ function extractColumnBulk(col, numRows, typeStr) {
|
|
|
242
334
|
* Same optimisation as extractColumnBulk but appends instead of creating new.
|
|
243
335
|
*/
|
|
244
336
|
function appendColumnBulk(target, col, numRows, typeStr) {
|
|
337
|
+
const scale = decimalScale(typeStr);
|
|
338
|
+
if (scale >= 0) {
|
|
339
|
+
for (let i = 0; i < numRows; i++) {
|
|
340
|
+
target.push(formatDecimal(col.get(i), scale));
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
245
344
|
if (isNumericArrowType(typeStr)) {
|
|
246
345
|
const arr = col.toArray();
|
|
247
346
|
for (let i = 0; i < arr.length; i++) {
|
|
@@ -264,12 +363,11 @@ export class WasmQueryEngine {
|
|
|
264
363
|
log(`query → ${sqlPreview}`);
|
|
265
364
|
const db = await getDB();
|
|
266
365
|
const conn = await db.connect();
|
|
267
|
-
await ensureGeoConversionDisabled(conn);
|
|
268
366
|
const tConn = performance.now();
|
|
269
367
|
log(`query → connected in ${elapsed(t0)}`);
|
|
270
368
|
try {
|
|
271
369
|
if (connId) {
|
|
272
|
-
await this.configureStorage(conn, connId);
|
|
370
|
+
await this.configureStorage(conn, connId, sql);
|
|
273
371
|
log(`query → storage configured in ${elapsed(tConn)}`);
|
|
274
372
|
}
|
|
275
373
|
const tQuery = performance.now();
|
|
@@ -290,14 +388,25 @@ export class WasmQueryEngine {
|
|
|
290
388
|
rows: []
|
|
291
389
|
};
|
|
292
390
|
}
|
|
391
|
+
// Arrow emits DECIMAL columns as multi-word BigInt / Uint32Array buffers.
|
|
392
|
+
// `String(rawDecimal)` yields the unscaled integer (or "0,0,0,0"),
|
|
393
|
+
// so rewrite each decimal cell through formatDecimal with the column scale.
|
|
394
|
+
const decimalCols = [];
|
|
395
|
+
for (let i = 0; i < cols.length; i++) {
|
|
396
|
+
const s = decimalScale(types[i]);
|
|
397
|
+
if (s >= 0)
|
|
398
|
+
decimalCols.push({ name: cols[i], scale: s });
|
|
399
|
+
}
|
|
293
400
|
// Extract rows directly — avoids Arrow version mismatch
|
|
294
401
|
const rows = result.toArray().map((row) => {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
402
|
+
const obj = typeof row.toJSON === 'function' ? row.toJSON() : {};
|
|
403
|
+
if (typeof row.toJSON !== 'function') {
|
|
404
|
+
for (const col of cols)
|
|
405
|
+
obj[col] = row[col];
|
|
406
|
+
}
|
|
407
|
+
for (const { name, scale } of decimalCols) {
|
|
408
|
+
obj[name] = formatDecimal(obj[name], scale);
|
|
409
|
+
}
|
|
301
410
|
return obj;
|
|
302
411
|
});
|
|
303
412
|
log(`query → done in ${elapsed(t0)}, ${numRows} rows, ${cols.length} cols`);
|
|
@@ -316,50 +425,39 @@ export class WasmQueryEngine {
|
|
|
316
425
|
log(`queryForMap → geomCol: ${geomCol}, type: ${geomColType}, crs: ${sourceCrs ?? 'WGS84'}`);
|
|
317
426
|
const db = await getDB();
|
|
318
427
|
const conn = await db.connect();
|
|
319
|
-
await ensureGeoConversionDisabled(conn);
|
|
320
428
|
try {
|
|
321
429
|
if (connId) {
|
|
322
|
-
await this.configureStorage(conn, connId);
|
|
430
|
+
await this.configureStorage(conn, connId, sql);
|
|
323
431
|
}
|
|
324
432
|
// Build geometry expression based on column type:
|
|
325
|
-
// - Native spatial types (GEOMETRY,
|
|
326
|
-
// - BLOB/BINARY →
|
|
433
|
+
// - Native spatial types (GEOMETRY, GEOMETRY('EPSG:...'), WKB_BLOB, etc.) → use directly
|
|
434
|
+
// - BLOB/BINARY → need explicit ST_GeomFromWKB
|
|
327
435
|
// - Everything else (VARCHAR, JSON, STRUCT, ...) → GeoJSON text
|
|
328
436
|
const quoted = `"${geomCol}"`;
|
|
329
437
|
const upper = geomColType.toUpperCase();
|
|
330
|
-
|
|
331
|
-
// Includes Arrow "Binary"/"LargeBinary" — DuckDB GEOMETRY columns from
|
|
332
|
-
// ST_ReadSHP/ST_Read appear as Arrow Binary but are NOT WKB blobs.
|
|
333
|
-
const isSpatialType = upper === 'GEOMETRY' ||
|
|
334
|
-
upper === 'GEOGRAPHY' ||
|
|
335
|
-
upper === 'WKB_BLOB' ||
|
|
336
|
-
upper.includes('POINT') ||
|
|
337
|
-
upper.includes('LINESTRING') ||
|
|
338
|
-
upper.includes('POLYGON') ||
|
|
339
|
-
upper.includes('BINARY'); // Arrow serialization of DuckDB GEOMETRY
|
|
340
|
-
// Actual WKB BLOB columns (e.g. GeoParquet) need explicit ST_GeomFromWKB
|
|
341
|
-
// because DuckDB has no implicit BLOB→GEOMETRY cast.
|
|
438
|
+
const spatialType = isSpatialColumnType(upper);
|
|
342
439
|
const isWkbBlob = upper === 'BLOB' || upper === 'BYTEA';
|
|
343
440
|
let wkbExpr;
|
|
344
441
|
let geomExpr;
|
|
345
442
|
if (isWkbBlob && !sourceCrs) {
|
|
346
443
|
// Already WKB — use directly, no spatial function calls needed.
|
|
347
|
-
// Avoids ST_GeomFromWKB which can fail if DuckDB auto-converted
|
|
348
|
-
// the column to GEOMETRY despite enable_geoparquet_conversion=false.
|
|
349
444
|
wkbExpr = quoted;
|
|
350
445
|
geomExpr = null; // geometry type detected client-side from WKB headers
|
|
351
446
|
}
|
|
352
447
|
else {
|
|
353
|
-
|
|
448
|
+
// For BLOB inputs with a known source CRS, attach it via ST_SetCRS so
|
|
449
|
+
// downstream ST_Transform can use the 2-arg form and the CRS propagates
|
|
450
|
+
// through any subsequent spatial ops (DuckDB v1.5+).
|
|
451
|
+
geomExpr = spatialType
|
|
354
452
|
? quoted
|
|
355
453
|
: isWkbBlob
|
|
356
|
-
?
|
|
454
|
+
? wrapWkbWithCrs(quoted, sourceCrs)
|
|
357
455
|
: `ST_GeomFromGeoJSON(${quoted})`;
|
|
358
456
|
// Re-project to WGS84 if the source CRS is not EPSG:4326/CRS84.
|
|
359
|
-
//
|
|
360
|
-
// target, matching the GeoParquet convention regardless of CRS authority.
|
|
457
|
+
// geometry_always_xy is set globally at DB init, so no per-call always_xy needed.
|
|
361
458
|
if (sourceCrs) {
|
|
362
|
-
|
|
459
|
+
const effectiveType = isWkbBlob ? `GEOMETRY('${sourceCrs}')` : geomColType;
|
|
460
|
+
geomExpr = buildTransformExpr(geomExpr, effectiveType, sourceCrs, DEFAULT_TARGET_CRS);
|
|
363
461
|
}
|
|
364
462
|
// ST_AsWKB needed — DuckDB GEOMETRY columns (from ST_ReadSHP, ST_Read)
|
|
365
463
|
// use an internal binary format, not WKB, even though Arrow reports Binary type.
|
|
@@ -416,18 +514,16 @@ export class WasmQueryEngine {
|
|
|
416
514
|
await conn.close();
|
|
417
515
|
}
|
|
418
516
|
}
|
|
419
|
-
async getSchema(connId,
|
|
517
|
+
async getSchema(connId, source) {
|
|
420
518
|
const t0 = performance.now();
|
|
421
|
-
log('getSchema →',
|
|
519
|
+
log('getSchema →', source.ref);
|
|
422
520
|
const db = await getDB();
|
|
423
521
|
const conn = await db.connect();
|
|
424
|
-
await ensureGeoConversionDisabled(conn);
|
|
425
522
|
try {
|
|
426
523
|
if (connId) {
|
|
427
|
-
await this.configureStorage(conn, connId);
|
|
524
|
+
await this.configureStorage(conn, connId, source.ref);
|
|
428
525
|
}
|
|
429
|
-
const
|
|
430
|
-
const result = await conn.query(`DESCRIBE SELECT * FROM ${source}`);
|
|
526
|
+
const result = await conn.query(`DESCRIBE SELECT * FROM ${source.ref}`);
|
|
431
527
|
const rows = result.toArray();
|
|
432
528
|
const schema = rows.map((row) => ({
|
|
433
529
|
name: row.column_name,
|
|
@@ -445,23 +541,24 @@ export class WasmQueryEngine {
|
|
|
445
541
|
await conn.close();
|
|
446
542
|
}
|
|
447
543
|
}
|
|
448
|
-
async getRowCount(connId,
|
|
544
|
+
async getRowCount(connId, source) {
|
|
449
545
|
const t0 = performance.now();
|
|
450
|
-
log('getRowCount →',
|
|
546
|
+
log('getRowCount →', source.ref);
|
|
451
547
|
const db = await getDB();
|
|
452
548
|
const conn = await db.connect();
|
|
453
|
-
await ensureGeoConversionDisabled(conn);
|
|
454
549
|
try {
|
|
455
550
|
if (connId) {
|
|
456
|
-
await this.configureStorage(conn, connId);
|
|
551
|
+
await this.configureStorage(conn, connId, source.ref);
|
|
457
552
|
}
|
|
458
553
|
// For Parquet files, try reading row count from file footer metadata first.
|
|
459
554
|
// This avoids parsing column types (which can fail on exotic geometry types)
|
|
460
555
|
// and is faster than SELECT COUNT(*) since it reads only footer bytes.
|
|
461
|
-
|
|
462
|
-
|
|
556
|
+
// Only applicable when we have a concrete file path — SQL-backed sources
|
|
557
|
+
// (attached DuckLake/DuckDB/SQLite tables) fall through to COUNT(*).
|
|
558
|
+
const isParquet = source.filePath ? /\.parquet$/i.test(source.filePath) : false;
|
|
559
|
+
if (isParquet && source.filePath) {
|
|
463
560
|
try {
|
|
464
|
-
const metaResult = await conn.query(`SELECT SUM(num_rows)::BIGINT as cnt FROM parquet_file_metadata('${
|
|
561
|
+
const metaResult = await conn.query(`SELECT SUM(num_rows)::BIGINT as cnt FROM parquet_file_metadata('${source.filePath}')`);
|
|
465
562
|
const metaRows = metaResult.toArray();
|
|
466
563
|
const count = Number(metaRows[0].cnt);
|
|
467
564
|
log(`getRowCount → ${count} via parquet_file_metadata in ${elapsed(t0)}`);
|
|
@@ -471,8 +568,7 @@ export class WasmQueryEngine {
|
|
|
471
568
|
logWarn('getRowCount → parquet_file_metadata failed, falling back to COUNT(*):', metaErr?.message ?? metaErr);
|
|
472
569
|
}
|
|
473
570
|
}
|
|
474
|
-
const
|
|
475
|
-
const result = await conn.query(`SELECT COUNT(*) as cnt FROM ${source}`);
|
|
571
|
+
const result = await conn.query(`SELECT COUNT(*) as cnt FROM ${source.ref}`);
|
|
476
572
|
const rows = result.toArray();
|
|
477
573
|
const count = Number(rows[0].cnt);
|
|
478
574
|
log(`getRowCount → ${count} via COUNT(*) in ${elapsed(t0)}`);
|
|
@@ -486,20 +582,18 @@ export class WasmQueryEngine {
|
|
|
486
582
|
await conn.close();
|
|
487
583
|
}
|
|
488
584
|
}
|
|
489
|
-
async getSchemaAndCrs(connId,
|
|
585
|
+
async getSchemaAndCrs(connId, source, findGeoCol) {
|
|
490
586
|
const t0 = performance.now();
|
|
491
|
-
log('getSchemaAndCrs →',
|
|
587
|
+
log('getSchemaAndCrs →', source.ref);
|
|
492
588
|
const db = await getDB();
|
|
493
589
|
const conn = await db.connect();
|
|
494
|
-
await ensureGeoConversionDisabled(conn);
|
|
495
590
|
try {
|
|
496
591
|
if (connId) {
|
|
497
|
-
await this.configureStorage(conn, connId);
|
|
592
|
+
await this.configureStorage(conn, connId, source.ref);
|
|
498
593
|
}
|
|
499
594
|
// Schema detection
|
|
500
595
|
const tSchema = performance.now();
|
|
501
|
-
const
|
|
502
|
-
const result = await conn.query(`DESCRIBE SELECT * FROM ${source}`);
|
|
596
|
+
const result = await conn.query(`DESCRIBE SELECT * FROM ${source.ref}`);
|
|
503
597
|
const schemaRows = result.toArray();
|
|
504
598
|
const schema = schemaRows.map((row) => ({
|
|
505
599
|
name: row.column_name,
|
|
@@ -516,7 +610,7 @@ export class WasmQueryEngine {
|
|
|
516
610
|
// CRS detection reusing the same connection
|
|
517
611
|
log(`getSchemaAndCrs → geo column: ${geomCol}, detecting CRS...`);
|
|
518
612
|
const tCrs = performance.now();
|
|
519
|
-
const crs = await this.detectCrsWithConn(conn,
|
|
613
|
+
const crs = await this.detectCrsWithConn(conn, source, geomCol);
|
|
520
614
|
log(`getSchemaAndCrs → CRS: ${crs ?? 'WGS84/null'} in ${elapsed(tCrs)}, total ${elapsed(t0)}`);
|
|
521
615
|
return { schema, geomCol, crs };
|
|
522
616
|
}
|
|
@@ -528,9 +622,18 @@ export class WasmQueryEngine {
|
|
|
528
622
|
await conn.close();
|
|
529
623
|
}
|
|
530
624
|
}
|
|
531
|
-
async configureStorage(conn, connId) {
|
|
625
|
+
async configureStorage(conn, connId, sourceRef) {
|
|
532
626
|
try {
|
|
533
|
-
//
|
|
627
|
+
// Defensive: callers may pass a destroyed Svelte $derived (returns a
|
|
628
|
+
// Symbol sentinel) across async boundaries. Template literals below
|
|
629
|
+
// would throw "can't convert symbol to string" and pollute logs.
|
|
630
|
+
if (typeof connId !== 'string' || !connId)
|
|
631
|
+
return;
|
|
632
|
+
// Presigned HTTPS refs are self-authenticating; no S3 SETs needed.
|
|
633
|
+
if (sourceRef && isHttpsSourceRef(sourceRef)) {
|
|
634
|
+
log('configureStorage → presigned HTTPS source, skipping S3 config');
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
534
637
|
const stored = localStorage.getItem('obstore-explore-connections');
|
|
535
638
|
if (!stored) {
|
|
536
639
|
log('configureStorage → no connections in localStorage');
|
|
@@ -542,9 +645,13 @@ export class WasmQueryEngine {
|
|
|
542
645
|
logWarn(`configureStorage → connection "${connId}" not found`);
|
|
543
646
|
return;
|
|
544
647
|
}
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
648
|
+
// For public and SAS-signed connections DuckDB hits the HTTPS URL
|
|
649
|
+
// directly — no S3 signing config needed. Saves a worker round-trip
|
|
650
|
+
// on every query for anonymous/public buckets (AWS, GCS, R2, etc.)
|
|
651
|
+
// and Azure Blob (SAS token embedded in the URL).
|
|
652
|
+
const mode = getAccessMode(connection);
|
|
653
|
+
if (mode !== 'signed-s3') {
|
|
654
|
+
log(`configureStorage → ${mode}, skipping S3 config`);
|
|
548
655
|
return;
|
|
549
656
|
}
|
|
550
657
|
// Batch all SET commands into a single query to minimize web worker round-trips
|
|
@@ -558,10 +665,16 @@ export class WasmQueryEngine {
|
|
|
558
665
|
if (connection.region) {
|
|
559
666
|
sets.push(`SET s3_region = '${connection.region}'`);
|
|
560
667
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
668
|
+
// Non-AWS providers with an empty `endpoint` field fall back to the
|
|
669
|
+
// provider registry's template, otherwise DuckDB routes them to AWS.
|
|
670
|
+
let endpoint = connection.endpoint;
|
|
671
|
+
if (!endpoint && connection.provider && connection.provider !== 's3') {
|
|
672
|
+
endpoint = resolveProviderEndpoint(connection.provider, connection.region);
|
|
673
|
+
}
|
|
674
|
+
if (endpoint) {
|
|
675
|
+
const endpointHost = endpoint.replace(/^https?:\/\//, '');
|
|
676
|
+
sets.push(`SET s3_endpoint = '${endpointHost}'`);
|
|
677
|
+
if (endpoint.startsWith('http://')) {
|
|
565
678
|
sets.push(`SET s3_use_ssl = false`);
|
|
566
679
|
}
|
|
567
680
|
}
|
|
@@ -577,17 +690,16 @@ export class WasmQueryEngine {
|
|
|
577
690
|
console.error(LOG_PREFIX, 'configureStorage error:', err);
|
|
578
691
|
}
|
|
579
692
|
}
|
|
580
|
-
async detectCrs(connId,
|
|
693
|
+
async detectCrs(connId, source, geomCol) {
|
|
581
694
|
const t0 = performance.now();
|
|
582
|
-
log(`detectCrs → standalone call for "${geomCol}"`,
|
|
695
|
+
log(`detectCrs → standalone call for "${geomCol}"`, source.ref);
|
|
583
696
|
const db = await getDB();
|
|
584
697
|
const conn = await db.connect();
|
|
585
|
-
await ensureGeoConversionDisabled(conn);
|
|
586
698
|
try {
|
|
587
699
|
if (connId) {
|
|
588
|
-
await this.configureStorage(conn, connId);
|
|
700
|
+
await this.configureStorage(conn, connId, source.ref);
|
|
589
701
|
}
|
|
590
|
-
const crs = await this.detectCrsWithConn(conn,
|
|
702
|
+
const crs = await this.detectCrsWithConn(conn, source, geomCol);
|
|
591
703
|
log(`detectCrs → ${crs ?? 'WGS84/null'} in ${elapsed(t0)}`);
|
|
592
704
|
return crs;
|
|
593
705
|
}
|
|
@@ -599,7 +711,36 @@ export class WasmQueryEngine {
|
|
|
599
711
|
await conn.close();
|
|
600
712
|
}
|
|
601
713
|
}
|
|
602
|
-
async detectCrsWithConn(conn,
|
|
714
|
+
async detectCrsWithConn(conn, source, geomCol) {
|
|
715
|
+
// Strategy 0: DuckDB v1.5 — CRS embedded in GEOMETRY column type
|
|
716
|
+
// e.g., GEOMETRY('EPSG:4326') → extract CRS directly from type string.
|
|
717
|
+
// Works uniformly for file-backed and SQL-backed (attached) sources.
|
|
718
|
+
try {
|
|
719
|
+
const t0 = performance.now();
|
|
720
|
+
const descResult = await conn.query(`SELECT column_type FROM (DESCRIBE SELECT * FROM ${source.ref}) WHERE column_name = '${geomCol}'`);
|
|
721
|
+
const descRows = descResult.toArray();
|
|
722
|
+
log(`detectCrs strategy 0 (column type) → ${descRows.length} rows in ${elapsed(t0)}`);
|
|
723
|
+
if (descRows.length > 0) {
|
|
724
|
+
const colType = String(descRows[0].column_type);
|
|
725
|
+
log(`detectCrs strategy 0 → type: "${colType}"`);
|
|
726
|
+
const result = extractCrsFromTypeString(colType);
|
|
727
|
+
if (result.found) {
|
|
728
|
+
log(`detectCrs strategy 0 → authoritative: ${result.crs ?? 'WGS84/null'} (skipping strategies 1-2)`);
|
|
729
|
+
return result.crs;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
log('detectCrs strategy 0 → skipped:', err?.message ?? err);
|
|
735
|
+
}
|
|
736
|
+
// Strategies 1 and 2 rely on file-level Parquet metadata functions.
|
|
737
|
+
// SQL-backed sources (attached DuckLake/DuckDB/SQLite tables) don't have
|
|
738
|
+
// a Parquet file path, so strategy 0 is authoritative for them.
|
|
739
|
+
if (!source.filePath) {
|
|
740
|
+
log('detectCrs → no filePath, skipping Parquet metadata strategies');
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
const path = source.filePath;
|
|
603
744
|
// Strategy 1: GeoParquet file-level metadata (geo key in KV metadata)
|
|
604
745
|
try {
|
|
605
746
|
const t1 = performance.now();
|
|
@@ -656,11 +797,10 @@ export class WasmQueryEngine {
|
|
|
656
797
|
log(`queryCancellable → ${sqlPreview}`);
|
|
657
798
|
const db = await getDB();
|
|
658
799
|
conn = await db.connect();
|
|
659
|
-
await ensureGeoConversionDisabled(conn);
|
|
660
800
|
log(`queryCancellable → connected in ${elapsed(t0)}`);
|
|
661
801
|
try {
|
|
662
802
|
if (connId) {
|
|
663
|
-
await this.configureStorage(conn, connId);
|
|
803
|
+
await this.configureStorage(conn, connId, sql);
|
|
664
804
|
}
|
|
665
805
|
const tQuery = performance.now();
|
|
666
806
|
const reader = await conn.send(sql);
|
|
@@ -668,6 +808,7 @@ export class WasmQueryEngine {
|
|
|
668
808
|
const rows = [];
|
|
669
809
|
let cols = [];
|
|
670
810
|
let types = [];
|
|
811
|
+
let decimalCols = [];
|
|
671
812
|
const batches = reader[Symbol.asyncIterator]();
|
|
672
813
|
let first = true;
|
|
673
814
|
while (true) {
|
|
@@ -679,6 +820,12 @@ export class WasmQueryEngine {
|
|
|
679
820
|
if (first && batch.schema) {
|
|
680
821
|
cols = batch.schema.fields.map((f) => f.name);
|
|
681
822
|
types = batch.schema.fields.map((f) => String(f.type));
|
|
823
|
+
decimalCols = [];
|
|
824
|
+
for (let i = 0; i < cols.length; i++) {
|
|
825
|
+
const s = decimalScale(types[i]);
|
|
826
|
+
if (s >= 0)
|
|
827
|
+
decimalCols.push({ name: cols[i], scale: s });
|
|
828
|
+
}
|
|
682
829
|
first = false;
|
|
683
830
|
}
|
|
684
831
|
for (const row of batch.toArray()) {
|
|
@@ -692,6 +839,12 @@ export class WasmQueryEngine {
|
|
|
692
839
|
json[key] = json[key].slice();
|
|
693
840
|
}
|
|
694
841
|
}
|
|
842
|
+
// DECIMAL raw values are BigInt / Uint32Array (unscaled). Convert
|
|
843
|
+
// to a human-readable string via formatDecimal — also drops the
|
|
844
|
+
// Uint32Array view, so stale-buffer reuse across batches is moot.
|
|
845
|
+
for (const { name, scale } of decimalCols) {
|
|
846
|
+
json[name] = formatDecimal(json[name], scale);
|
|
847
|
+
}
|
|
695
848
|
rows.push(json);
|
|
696
849
|
}
|
|
697
850
|
}
|
|
@@ -732,37 +885,30 @@ export class WasmQueryEngine {
|
|
|
732
885
|
log(`queryForMapCancellable → geomCol: ${geomCol}, type: ${geomColType}, crs: ${sourceCrs ?? 'WGS84'}`);
|
|
733
886
|
const db = await getDB();
|
|
734
887
|
conn = await db.connect();
|
|
735
|
-
await ensureGeoConversionDisabled(conn);
|
|
736
888
|
try {
|
|
737
889
|
if (connId) {
|
|
738
|
-
await this.configureStorage(conn, connId);
|
|
890
|
+
await this.configureStorage(conn, connId, sql);
|
|
739
891
|
}
|
|
740
892
|
// Build geometry expression (same logic as queryForMap)
|
|
741
893
|
const quoted = `"${geomCol}"`;
|
|
742
894
|
const upper = geomColType.toUpperCase();
|
|
743
|
-
const
|
|
744
|
-
upper === 'GEOGRAPHY' ||
|
|
745
|
-
upper === 'WKB_BLOB' ||
|
|
746
|
-
upper.includes('POINT') ||
|
|
747
|
-
upper.includes('LINESTRING') ||
|
|
748
|
-
upper.includes('POLYGON') ||
|
|
749
|
-
upper.includes('BINARY');
|
|
895
|
+
const spatialType = isSpatialColumnType(upper);
|
|
750
896
|
const isWkbBlob = upper === 'BLOB' || upper === 'BYTEA';
|
|
751
897
|
let wkbExpr;
|
|
752
898
|
let geomExpr;
|
|
753
899
|
if (isWkbBlob && !sourceCrs) {
|
|
754
|
-
// Already WKB — use directly, no spatial function calls needed.
|
|
755
900
|
wkbExpr = quoted;
|
|
756
|
-
geomExpr = null;
|
|
901
|
+
geomExpr = null;
|
|
757
902
|
}
|
|
758
903
|
else {
|
|
759
|
-
geomExpr =
|
|
904
|
+
geomExpr = spatialType
|
|
760
905
|
? quoted
|
|
761
906
|
: isWkbBlob
|
|
762
|
-
?
|
|
907
|
+
? wrapWkbWithCrs(quoted, sourceCrs)
|
|
763
908
|
: `ST_GeomFromGeoJSON(${quoted})`;
|
|
764
909
|
if (sourceCrs) {
|
|
765
|
-
|
|
910
|
+
const effectiveType = isWkbBlob ? `GEOMETRY('${sourceCrs}')` : geomColType;
|
|
911
|
+
geomExpr = buildTransformExpr(geomExpr, effectiveType, sourceCrs, DEFAULT_TARGET_CRS);
|
|
766
912
|
}
|
|
767
913
|
wkbExpr = `ST_AsWKB(${geomExpr})`;
|
|
768
914
|
}
|
|
@@ -867,10 +1013,24 @@ export class WasmQueryEngine {
|
|
|
867
1013
|
}
|
|
868
1014
|
finally {
|
|
869
1015
|
dbPromise = null;
|
|
870
|
-
geoConversionGlobal = false;
|
|
871
1016
|
log('forceCancel → done, next getDB() will reinitialize');
|
|
872
1017
|
}
|
|
873
1018
|
}
|
|
1019
|
+
async registerFileBuffer(name, buffer) {
|
|
1020
|
+
const db = await getDB();
|
|
1021
|
+
await db.registerFileBuffer(name, buffer);
|
|
1022
|
+
log(`registerFileBuffer → "${name}" (${buffer.byteLength} bytes)`);
|
|
1023
|
+
}
|
|
1024
|
+
async dropFile(name) {
|
|
1025
|
+
const db = await getDB();
|
|
1026
|
+
try {
|
|
1027
|
+
await db.dropFile(name);
|
|
1028
|
+
log(`dropFile → "${name}"`);
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
// Ignore — file may not exist
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
874
1034
|
async releaseMemory() {
|
|
875
1035
|
const db = await getDB();
|
|
876
1036
|
const conn = await db.connect();
|
|
@@ -5,6 +5,15 @@ export interface ListPage {
|
|
|
5
5
|
continuationToken?: string;
|
|
6
6
|
hasMore: boolean;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Thrown by adapters when the server returns 401 or 403 on an anonymous
|
|
10
|
+
* request. The browser store catches this to trigger a credential prompt
|
|
11
|
+
* for auto-detected `?url=` connections that turned out to be private.
|
|
12
|
+
*/
|
|
13
|
+
export declare class AuthRequiredError extends Error {
|
|
14
|
+
readonly status: number;
|
|
15
|
+
constructor(status: number, message: string);
|
|
16
|
+
}
|
|
8
17
|
export interface StorageAdapter {
|
|
9
18
|
list(path: string, signal?: AbortSignal): Promise<FileEntry[]>;
|
|
10
19
|
read(path: string, offset?: number, length?: number, signal?: AbortSignal): Promise<Uint8Array>;
|