@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.
Files changed (89) hide show
  1. package/README.md +3 -1
  2. package/dist/components/browser/FileBrowser.svelte +25 -14
  3. package/dist/components/browser/FileTreeSidebar.svelte +43 -7
  4. package/dist/components/layout/ConnectionDialog.svelte +100 -1
  5. package/dist/components/layout/Sidebar.svelte +70 -25
  6. package/dist/components/viewers/ArchiveViewer.svelte +4 -4
  7. package/dist/components/viewers/CodeViewer.svelte +44 -5
  8. package/dist/components/viewers/CogControls.svelte +208 -0
  9. package/dist/components/viewers/CogControls.svelte.d.ts +12 -0
  10. package/dist/components/viewers/CogViewer.svelte +373 -1162
  11. package/dist/components/viewers/CogViewer.svelte.d.ts +1 -1
  12. package/dist/components/viewers/CopcViewer.svelte +20 -2
  13. package/dist/components/viewers/DatabaseViewer.svelte +345 -37
  14. package/dist/components/viewers/FlatGeobufViewer.svelte +15 -9
  15. package/dist/components/viewers/MarkdownViewer.svelte +1 -1
  16. package/dist/components/viewers/PmtilesViewer.svelte +2 -2
  17. package/dist/components/viewers/StacMapViewer.svelte +25 -9
  18. package/dist/components/viewers/TableViewer.svelte +162 -51
  19. package/dist/components/viewers/ZarrMapViewer.svelte +33 -4
  20. package/dist/components/viewers/ZarrViewer.svelte +3 -6
  21. package/dist/components/viewers/pmtiles/PmtilesMapView.svelte +0 -1
  22. package/dist/constants.d.ts +6 -2
  23. package/dist/constants.js +6 -2
  24. package/dist/file-icons/index.d.ts +1 -1
  25. package/dist/file-icons/index.js +12 -2
  26. package/dist/i18n/ar.js +25 -0
  27. package/dist/i18n/en.js +25 -0
  28. package/dist/i18n/index.svelte.d.ts +0 -1
  29. package/dist/i18n/index.svelte.js +0 -3
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.js +1 -0
  32. package/dist/query/engine.d.ts +20 -4
  33. package/dist/query/index.d.ts +2 -1
  34. package/dist/query/index.js +1 -0
  35. package/dist/query/source.d.ts +42 -0
  36. package/dist/query/source.js +54 -0
  37. package/dist/query/wasm.d.ts +7 -5
  38. package/dist/query/wasm.js +267 -107
  39. package/dist/storage/adapter.d.ts +9 -0
  40. package/dist/storage/adapter.js +13 -1
  41. package/dist/storage/browser-azure.d.ts +1 -1
  42. package/dist/storage/browser-azure.js +4 -0
  43. package/dist/storage/browser-cloud.d.ts +1 -1
  44. package/dist/storage/browser-cloud.js +7 -0
  45. package/dist/storage/presign.d.ts +13 -0
  46. package/dist/storage/presign.js +55 -0
  47. package/dist/storage/providers.d.ts +53 -0
  48. package/dist/storage/providers.js +171 -0
  49. package/dist/stores/browser.svelte.d.ts +2 -0
  50. package/dist/stores/browser.svelte.js +17 -1
  51. package/dist/stores/files.svelte.d.ts +1 -2
  52. package/dist/stores/files.svelte.js +1 -2
  53. package/dist/stores/tabs.svelte.d.ts +9 -2
  54. package/dist/stores/tabs.svelte.js +11 -2
  55. package/dist/types.d.ts +11 -0
  56. package/dist/utils/cog.d.ts +244 -0
  57. package/dist/utils/cog.js +1039 -0
  58. package/dist/utils/deck.d.ts +0 -18
  59. package/dist/utils/deck.js +0 -36
  60. package/dist/utils/geometry-type.d.ts +52 -0
  61. package/dist/utils/geometry-type.js +76 -0
  62. package/dist/utils/markdown-sql.d.ts +1 -1
  63. package/dist/utils/markdown-sql.js +3 -4
  64. package/dist/utils/pmtiles-tile.d.ts +0 -2
  65. package/dist/utils/pmtiles-tile.js +0 -8
  66. package/dist/utils/url-state.d.ts +6 -0
  67. package/dist/utils/url-state.js +34 -26
  68. package/dist/utils/url.d.ts +26 -9
  69. package/dist/utils/url.js +52 -25
  70. package/dist/utils/wkb.js +22 -8
  71. package/dist/utils/zarr-tab.d.ts +22 -0
  72. package/dist/utils/zarr-tab.js +30 -0
  73. package/dist/utils/zarr.d.ts +0 -2
  74. package/dist/utils/zarr.js +73 -44
  75. package/package.json +47 -43
  76. package/dist/components/ui/tabs/index.d.ts +0 -5
  77. package/dist/components/ui/tabs/index.js +0 -7
  78. package/dist/components/ui/tabs/tabs-content.svelte +0 -17
  79. package/dist/components/ui/tabs/tabs-content.svelte.d.ts +0 -4
  80. package/dist/components/ui/tabs/tabs-list.svelte +0 -16
  81. package/dist/components/ui/tabs/tabs-list.svelte.d.ts +0 -4
  82. package/dist/components/ui/tabs/tabs-trigger.svelte +0 -20
  83. package/dist/components/ui/tabs/tabs-trigger.svelte.d.ts +0 -4
  84. package/dist/components/ui/tabs/tabs.svelte +0 -19
  85. package/dist/components/ui/tabs/tabs.svelte.d.ts +0 -4
  86. package/dist/components/viewers/MapViewer.svelte +0 -234
  87. package/dist/components/viewers/MapViewer.svelte.d.ts +0 -7
  88. package/dist/components/viewers/StyleEditorOverlay.svelte +0 -27
  89. package/dist/components/viewers/StyleEditorOverlay.svelte.d.ts +0 -7
@@ -1,7 +1,9 @@
1
1
  import { DEFAULT_TARGET_CRS, DUCKDB_INIT_TIMEOUT_MS, WGS84_CODES } from '../constants.js';
2
- import { buildDuckDbSource } from '../file-icons/index.js';
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
- await withTimeout(conn.query('INSTALL httpfs; LOAD httpfs; INSTALL spatial; LOAD spatial;'), INIT_TIMEOUT_MS, 'extension install (httpfs + spatial)');
70
- // Disable auto-conversion of GeoParquet metadata GEOMETRY type.
71
- // Some files use legacy GeoParquet metadata (schema_version 0.x without
72
- // "version" field) which causes DuckDB's spatial extension to throw
73
- // "Geoparquet metadata does not have a version". We handle geometry
74
- // detection, CRS, and WKB conversion ourselves via hyparquet metadata
75
- // and explicit ST_GeomFromWKB() calls, so auto-conversion is not needed.
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 enable_geoparquet_conversion = false');
79
- geoConversionGlobal = true;
82
+ await conn.query('SET GLOBAL geometry_always_xy = true');
80
83
  }
81
84
  catch {
82
- // SET GLOBAL not supportedfall back to per-connection SET
83
- await conn.query('SET enable_geoparquet_conversion = false');
85
+ logWarn('geometry_always_xy not availableST_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
- let geoConversionGlobal = false;
109
+ // ─── Geometry type helpers ────────────────────────────────────────────
100
110
  /**
101
- * Ensure GeoParquet auto-conversion is disabled on this connection.
102
- * If SET GLOBAL succeeded during init, this is a no-op.
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
- async function ensureGeoConversionDisabled(conn) {
106
- if (geoConversionGlobal)
107
- return;
108
- await conn.query('SET enable_geoparquet_conversion = false');
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
- /** True if the Arrow type string represents a numeric primitive (zero-copy .toArray()). */
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
- if (typeof row.toJSON === 'function')
296
- return row.toJSON();
297
- // Fallback: manually build row object
298
- const obj = {};
299
- for (const col of cols)
300
- obj[col] = row[col];
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, WKB_BLOB, POINT, etc.) → use directly
326
- // - BLOB/BINARY → DuckDB implicitly casts BLOB→GEOMETRY, use directly
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
- // Spatial types that ST_AsWKB accepts directly (GEOMETRY, WKB_BLOB, etc.).
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
- geomExpr = isSpatialType
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
- ? `ST_GeomFromWKB(${quoted})`
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
- // always_xy := true forces lon/lat (x/y) axis order for both source and
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
- geomExpr = `ST_Transform(${geomExpr}, '${sourceCrs}', '${DEFAULT_TARGET_CRS}', always_xy := true)`;
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, path) {
517
+ async getSchema(connId, source) {
420
518
  const t0 = performance.now();
421
- log('getSchema →', path);
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 source = buildDuckDbSource(path, path);
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, path) {
544
+ async getRowCount(connId, source) {
449
545
  const t0 = performance.now();
450
- log('getRowCount →', path);
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
- const isParquet = /\.parquet$/i.test(path);
462
- if (isParquet) {
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('${path}')`);
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 source = buildDuckDbSource(path, path);
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, path, findGeoCol) {
585
+ async getSchemaAndCrs(connId, source, findGeoCol) {
490
586
  const t0 = performance.now();
491
- log('getSchemaAndCrs →', path);
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 source = buildDuckDbSource(path, path);
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, path, geomCol);
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
- // Read connection metadata from localStorage
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
- // Azure uses direct HTTPS URLs with SAS token no S3 config needed
546
- if (connection.provider === 'azure') {
547
- log('configureStorage Azure provider, skipping S3 config');
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
- if (connection.endpoint) {
562
- const endpoint = connection.endpoint.replace(/^https?:\/\//, '');
563
- sets.push(`SET s3_endpoint = '${endpoint}'`);
564
- if (connection.endpoint.startsWith('http://')) {
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, path, geomCol) {
693
+ async detectCrs(connId, source, geomCol) {
581
694
  const t0 = performance.now();
582
- log(`detectCrs → standalone call for "${geomCol}"`, path);
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, path, geomCol);
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, path, geomCol) {
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 isSpatialType = upper === 'GEOMETRY' ||
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; // geometry type detected client-side from WKB headers
901
+ geomExpr = null;
757
902
  }
758
903
  else {
759
- geomExpr = isSpatialType
904
+ geomExpr = spatialType
760
905
  ? quoted
761
906
  : isWkbBlob
762
- ? `ST_GeomFromWKB(${quoted})`
907
+ ? wrapWkbWithCrs(quoted, sourceCrs)
763
908
  : `ST_GeomFromGeoJSON(${quoted})`;
764
909
  if (sourceCrs) {
765
- geomExpr = `ST_Transform(${geomExpr}, '${sourceCrs}', '${DEFAULT_TARGET_CRS}', always_xy := true)`;
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>;