@loaders.gl/scan 5.0.0-alpha.3

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 (52) hide show
  1. package/LICENSE +52 -0
  2. package/README.md +219 -0
  3. package/dist/delta.cjs +24246 -0
  4. package/dist/delta.cjs.map +6 -0
  5. package/dist/delta.d.ts +11 -0
  6. package/dist/delta.d.ts.map +1 -0
  7. package/dist/delta.js +12 -0
  8. package/dist/delta.js.map +1 -0
  9. package/dist/dist.min.js +18475 -0
  10. package/dist/federated-table-scan-source.d.ts +116 -0
  11. package/dist/federated-table-scan-source.d.ts.map +1 -0
  12. package/dist/federated-table-scan-source.js +691 -0
  13. package/dist/federated-table-scan-source.js.map +1 -0
  14. package/dist/hudi.cjs +24065 -0
  15. package/dist/hudi.cjs.map +6 -0
  16. package/dist/hudi.d.ts +54 -0
  17. package/dist/hudi.d.ts.map +1 -0
  18. package/dist/hudi.js +124 -0
  19. package/dist/hudi.js.map +1 -0
  20. package/dist/iceberg.cjs +25862 -0
  21. package/dist/iceberg.cjs.map +6 -0
  22. package/dist/iceberg.d.ts +12 -0
  23. package/dist/iceberg.d.ts.map +1 -0
  24. package/dist/iceberg.js +12 -0
  25. package/dist/iceberg.js.map +1 -0
  26. package/dist/index.cjs +3485 -0
  27. package/dist/index.cjs.map +6 -0
  28. package/dist/index.d.ts +15 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +14 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/scan-engine.d.ts +44 -0
  33. package/dist/scan-engine.d.ts.map +1 -0
  34. package/dist/scan-engine.js +49 -0
  35. package/dist/scan-engine.js.map +1 -0
  36. package/dist/scan-query.d.ts +24 -0
  37. package/dist/scan-query.d.ts.map +1 -0
  38. package/dist/scan-query.js +5 -0
  39. package/dist/scan-query.js.map +1 -0
  40. package/dist/vector-table-scan-source.d.ts +90 -0
  41. package/dist/vector-table-scan-source.d.ts.map +1 -0
  42. package/dist/vector-table-scan-source.js +215 -0
  43. package/dist/vector-table-scan-source.js.map +1 -0
  44. package/package.json +66 -0
  45. package/src/delta.ts +18 -0
  46. package/src/federated-table-scan-source.ts +991 -0
  47. package/src/hudi.ts +200 -0
  48. package/src/iceberg.ts +36 -0
  49. package/src/index.ts +97 -0
  50. package/src/scan-engine.ts +96 -0
  51. package/src/scan-query.ts +27 -0
  52. package/src/vector-table-scan-source.ts +294 -0
@@ -0,0 +1,691 @@
1
+ // loaders.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+ import * as arrow from 'apache-arrow';
5
+ import { createScanQueryMetadata, emitScanExecutionTelemetry, explainTableQuery, getColumnarPredicateColumns, planTableQuery } from '@loaders.gl/loader-utils';
6
+ import { convertArrowToSchema, convertBatch, convertSchemaToArrow } from '@loaders.gl/schema-utils';
7
+ import { queryArrowTable } from '@loaders.gl/sql';
8
+ /** Correctness capabilities of the federated append executor. */
9
+ export const FEDERATED_TABLE_QUERY_CAPABILITIES = Object.freeze({
10
+ predicate: 'residual',
11
+ projection: 'pushdown+residual',
12
+ limit: 'pushdown+residual',
13
+ streaming: true,
14
+ cancellation: true
15
+ });
16
+ let nextFederatedSourceId = 0;
17
+ /**
18
+ * Resolves managed table sources and appends their results as one ordered Arrow stream.
19
+ *
20
+ * `DataSourceManager` remains the single registry and lifecycle owner. This adapter subscribes for
21
+ * the duration of metadata discovery or iteration, so deferred and non-persistent sources use the
22
+ * same replacement, retention, and cleanup system as the rest of loaders.gl.
23
+ */
24
+ export class FederatedTableScanSource {
25
+ /** Shared manager used as the authoritative named-source registry. */
26
+ dataSourceManager;
27
+ /** Immutable sources in physical append order. */
28
+ sources;
29
+ /** Schema reconciliation policy. */
30
+ schemaPolicy;
31
+ /** Optional caller-declared normalized output schema. */
32
+ outputSchema;
33
+ /** Optional display name reported through scan metadata. */
34
+ name;
35
+ /** Human-readable description reported through scan metadata. */
36
+ description;
37
+ /** Stable id used to isolate this adapter's manager subscriptions. */
38
+ instanceId = nextFederatedSourceId++;
39
+ /** Monotonic id used to isolate overlapping operations on this adapter. */
40
+ nextOperationId = 0;
41
+ /** Creates an ordered federated source backed by one shared DataSourceManager. */
42
+ constructor(dataSourceManager, options) {
43
+ if (!options.sources.length) {
44
+ throw new Error('Federated table scans require at least one source.');
45
+ }
46
+ if (options.schemaPolicy &&
47
+ options.schemaPolicy !== 'strict' &&
48
+ options.schemaPolicy !== 'union') {
49
+ throw new Error(`Unsupported federated schema policy: ${options.schemaPolicy}`);
50
+ }
51
+ this.dataSourceManager = dataSourceManager;
52
+ this.sources = Object.freeze(options.sources.map(cloneSourceEntry));
53
+ this.schemaPolicy = options.schemaPolicy || 'strict';
54
+ this.outputSchema = options.outputSchema ? cloneSchema(options.outputSchema) : undefined;
55
+ this.name = options.name;
56
+ this.description = options.description || `${this.sources.length} ordered table sources`;
57
+ }
58
+ /** Discovers and reconciles every child schema without reading federated result batches. */
59
+ async getQueryMetadata(options = {}) {
60
+ const consumerId = this.createConsumerId('metadata');
61
+ try {
62
+ const plan = await this.resolvePlan(consumerId, options.signal);
63
+ return createScanQueryMetadata({
64
+ sourceType: 'federated-table',
65
+ queryType: 'table',
66
+ execution: { status: 'supported', method: 'read' },
67
+ name: this.name,
68
+ description: this.description,
69
+ schema: plan.schema,
70
+ capabilities: { table: FEDERATED_TABLE_QUERY_CAPABILITIES },
71
+ statistics: getFederatedStatistics(plan.sources)
72
+ });
73
+ }
74
+ finally {
75
+ this.dataSourceManager.unsubscribe({ consumerId });
76
+ }
77
+ }
78
+ /** Explains source resolution, schema reconciliation, and the global table query. */
79
+ async explain(options = {}) {
80
+ const consumerId = this.createConsumerId('explain');
81
+ try {
82
+ const plan = await this.resolvePlan(consumerId, options.signal);
83
+ const explanation = explainTableQuery(plan.schema.fields.map(field => field.name), options, FEDERATED_TABLE_QUERY_CAPABILITIES);
84
+ return Object.freeze({
85
+ ...explanation,
86
+ schemaPolicy: this.schemaPolicy,
87
+ sources: Object.freeze(plan.sources.map(source => createSourceExplanation(source, plan.schema)))
88
+ });
89
+ }
90
+ finally {
91
+ this.dataSourceManager.unsubscribe({ consumerId });
92
+ }
93
+ }
94
+ /**
95
+ * Emits source and batch ordered Arrow results with one global limit and batch provenance.
96
+ *
97
+ * Returning early from the iterator closes the active child iterator and releases every manager
98
+ * subscription. Later sources are never read once the global limit has been satisfied.
99
+ */
100
+ async *read(options = {}) {
101
+ const consumerId = this.createConsumerId('read');
102
+ const startedAt = Date.now();
103
+ const telemetry = {
104
+ status: 'early-terminated',
105
+ sourcesPlanned: this.sources.length,
106
+ sourcesRead: 0,
107
+ batchesRead: 0,
108
+ rowsRead: 0,
109
+ rowsTested: 0,
110
+ rowsRetained: 0,
111
+ rowsReturned: 0,
112
+ bytesFetched: 0,
113
+ filesOpened: 0,
114
+ tasksOpened: 0,
115
+ rowsPruned: 0,
116
+ durationMilliseconds: 0,
117
+ sources: []
118
+ };
119
+ try {
120
+ const plan = await this.resolvePlan(consumerId, options.signal);
121
+ const scanStep = planTableQuery(plan.schema.fields.map(field => field.name), options)[0];
122
+ let remaining = options.limit ?? Number.POSITIVE_INFINITY;
123
+ if (remaining <= 0) {
124
+ telemetry.earlyTerminationReason = 'limit';
125
+ return;
126
+ }
127
+ for (const resolvedSource of plan.sources) {
128
+ throwIfAborted(options.signal);
129
+ if (remaining <= 0) {
130
+ telemetry.earlyTerminationReason = 'limit';
131
+ return;
132
+ }
133
+ const sourceStartedAt = Date.now();
134
+ let childTelemetry;
135
+ let sourceCompleted = false;
136
+ let sourceError;
137
+ let sourceBatchesDecoded = 0;
138
+ let sourceRowsRead = 0;
139
+ let sourceRowsTested = 0;
140
+ let sourceRowsRetained = 0;
141
+ let sourceRowsReturned = 0;
142
+ const sourceReadOptions = createSourceReadOptions(resolvedSource, scanStep.columns, remaining, options, value => {
143
+ childTelemetry = value;
144
+ });
145
+ const sourceResidualPredicate = getSourceResidualPredicate(resolvedSource);
146
+ let sourceRemaining = resolvedSource.entry.query?.limit ?? Number.POSITIVE_INFINITY;
147
+ telemetry.sourcesRead++;
148
+ let sourceBatchIndex = 0;
149
+ try {
150
+ for await (const batch of resolvedSource.source.read(sourceReadOptions)) {
151
+ throwIfAborted(options.signal);
152
+ if (remaining <= 0) {
153
+ telemetry.earlyTerminationReason = 'limit';
154
+ return;
155
+ }
156
+ if (sourceRemaining <= 0)
157
+ break;
158
+ if (batch.batchType !== 'data')
159
+ continue;
160
+ telemetry.batchesRead++;
161
+ telemetry.rowsRead += batch.length;
162
+ sourceBatchesDecoded++;
163
+ sourceRowsRead += batch.length;
164
+ if (batch.length <= 0)
165
+ continue;
166
+ const physicalTable = convertBatch(batch.schema ? batch : { ...batch, schema: resolvedSource.metadata.schema }, 'arrow-table');
167
+ const sourceResult = queryArrowTable(physicalTable, {
168
+ predicate: sourceResidualPredicate,
169
+ limit: Number.isFinite(sourceRemaining) ? sourceRemaining : undefined,
170
+ signal: options.signal
171
+ });
172
+ if (sourceResidualPredicate) {
173
+ sourceRowsTested += physicalTable.data.numRows;
174
+ sourceRowsRetained += sourceResult.data.numRows;
175
+ }
176
+ sourceRemaining -= sourceResult.data.numRows;
177
+ const currentSourceBatchIndex = sourceBatchIndex++;
178
+ if (!sourceResult.data.numRows)
179
+ continue;
180
+ const canonicalTable = createCanonicalArrowTable(plan.schema, resolvedSource, sourceResult);
181
+ const result = queryArrowTable(canonicalTable, {
182
+ predicate: options.predicate,
183
+ columns: options.columns,
184
+ limit: Number.isFinite(remaining) ? remaining : undefined,
185
+ signal: options.signal
186
+ });
187
+ if (options.predicate) {
188
+ telemetry.rowsTested += canonicalTable.data.numRows;
189
+ telemetry.rowsRetained += result.data.numRows;
190
+ }
191
+ const resultLength = result.data.numRows;
192
+ if (!resultLength)
193
+ continue;
194
+ const provenance = Object.freeze({
195
+ sourceId: resolvedSource.entry.dataSourceId,
196
+ sourceIndex: resolvedSource.sourceIndex,
197
+ sourceBatchIndex: currentSourceBatchIndex,
198
+ sourceMetadata: batch.metadata
199
+ });
200
+ telemetry.rowsReturned += resultLength;
201
+ sourceRowsReturned += resultLength;
202
+ yield {
203
+ batchType: 'data',
204
+ shape: 'arrow-table',
205
+ schema: convertArrowToSchema(result.data.schema),
206
+ data: result.data,
207
+ length: resultLength,
208
+ metadata: provenance,
209
+ sourceId: provenance.sourceId,
210
+ sourceIndex: provenance.sourceIndex,
211
+ sourceBatchIndex: provenance.sourceBatchIndex
212
+ };
213
+ remaining -= resultLength;
214
+ }
215
+ sourceCompleted = true;
216
+ }
217
+ catch (error) {
218
+ sourceError = error;
219
+ throw error;
220
+ }
221
+ finally {
222
+ const sourceTelemetry = createSourceTelemetry({
223
+ resolvedSource,
224
+ childTelemetry,
225
+ sourceStartedAt,
226
+ sourceCompleted,
227
+ sourceError,
228
+ signal: options.signal,
229
+ batchesDecoded: sourceBatchesDecoded,
230
+ rowsRead: sourceRowsRead,
231
+ rowsTested: sourceRowsTested,
232
+ rowsRetained: sourceRowsRetained,
233
+ rowsReturned: sourceRowsReturned
234
+ });
235
+ telemetry.sources.push(sourceTelemetry);
236
+ telemetry.bytesFetched += sourceTelemetry.bytesFetched || 0;
237
+ telemetry.filesOpened += sourceTelemetry.filesOpened || 0;
238
+ telemetry.tasksOpened += sourceTelemetry.tasksOpened || 0;
239
+ telemetry.rowsPruned += sourceTelemetry.rowsPruned || 0;
240
+ }
241
+ }
242
+ telemetry.status = 'completed';
243
+ }
244
+ catch (error) {
245
+ telemetry.status = options.signal?.aborted ? 'cancelled' : 'failed';
246
+ telemetry.error = error;
247
+ throw error;
248
+ }
249
+ finally {
250
+ this.dataSourceManager.unsubscribe({ consumerId });
251
+ telemetry.durationMilliseconds = Date.now() - startedAt;
252
+ if (telemetry.status === 'early-terminated' && !telemetry.earlyTerminationReason) {
253
+ telemetry.earlyTerminationReason = 'consumer-return';
254
+ }
255
+ emitScanExecutionTelemetry(options.onTelemetry, Object.freeze({
256
+ ...telemetry,
257
+ batchesDecoded: telemetry.batchesRead,
258
+ sources: Object.freeze([...telemetry.sources])
259
+ }));
260
+ }
261
+ }
262
+ /** Resolves managed sources, discovers their schemas, and creates one compatibility plan. */
263
+ async resolvePlan(consumerId, signal) {
264
+ const sources = [];
265
+ for (let sourceIndex = 0; sourceIndex < this.sources.length; sourceIndex++) {
266
+ throwIfAborted(signal);
267
+ const entry = this.sources[sourceIndex];
268
+ const source = await resolveManagedSource(this.dataSourceManager, entry.dataSourceId, consumerId, String(sourceIndex), signal);
269
+ if (typeof source.getQueryMetadata !== 'function' || typeof source.read !== 'function') {
270
+ throw new Error(`Managed source does not implement TableScanSource: ${entry.dataSourceId}`);
271
+ }
272
+ const metadata = await source.getQueryMetadata({ signal });
273
+ if (metadata.execution.status !== 'supported' || metadata.execution.method !== 'read') {
274
+ throw new Error(`Federated table source does not support read(): ${entry.dataSourceId}`);
275
+ }
276
+ if (metadata.queryType !== 'table') {
277
+ throw new Error(`Federated source is not a table source: ${entry.dataSourceId}`);
278
+ }
279
+ const fields = resolveMappedFields(entry, metadata);
280
+ sources.push({
281
+ entry,
282
+ source,
283
+ metadata,
284
+ sourceIndex,
285
+ fields,
286
+ sourceNameByOutputName: new Map(fields.map(field => [field.outputName, field.sourceName]))
287
+ });
288
+ }
289
+ return Object.freeze({
290
+ schema: reconcileSchemas(sources, this.schemaPolicy, this.outputSchema),
291
+ sources: Object.freeze(sources)
292
+ });
293
+ }
294
+ /** Returns a collision-free manager consumer id for one operation. */
295
+ createConsumerId(operation) {
296
+ return `federated-table:${this.instanceId}:${operation}:${this.nextOperationId++}`;
297
+ }
298
+ }
299
+ /** Clones the public entry so later caller mutations cannot change a running plan. */
300
+ function cloneSourceEntry(entry) {
301
+ if (!entry.dataSourceId) {
302
+ throw new Error('Federated table source ids must be non-empty.');
303
+ }
304
+ return Object.freeze({
305
+ dataSourceId: entry.dataSourceId,
306
+ query: entry.query
307
+ ? Object.freeze({
308
+ ...entry.query,
309
+ columns: entry.query.columns ? Object.freeze([...entry.query.columns]) : undefined
310
+ })
311
+ : undefined,
312
+ columnMapping: entry.columnMapping ? Object.freeze({ ...entry.columnMapping }) : undefined
313
+ });
314
+ }
315
+ /** Selects source-local fields and applies explicit output names. */
316
+ function resolveMappedFields(entry, metadata) {
317
+ const sourceFields = new Map(metadata.schema.fields.map(field => [field.name, field]));
318
+ for (const sourceName of Object.keys(entry.columnMapping || {})) {
319
+ if (!sourceFields.has(sourceName)) {
320
+ throw new Error(`Federated column mapping source not found in ${entry.dataSourceId}: ${sourceName}`);
321
+ }
322
+ }
323
+ const selectedNames = entry.query?.columns || metadata.schema.fields.map(field => field.name);
324
+ if (!selectedNames.length) {
325
+ throw new Error(`Federated source projection must not be empty: ${entry.dataSourceId}`);
326
+ }
327
+ const outputNames = new Set();
328
+ return Object.freeze(selectedNames.map(sourceName => {
329
+ const field = sourceFields.get(sourceName);
330
+ if (!field) {
331
+ throw new Error(`Federated source column not found in ${entry.dataSourceId}: ${sourceName}`);
332
+ }
333
+ const outputName = entry.columnMapping?.[sourceName] ?? sourceName;
334
+ if (!outputName) {
335
+ throw new Error(`Federated output column names must be non-empty: ${sourceName}`);
336
+ }
337
+ if (outputNames.has(outputName)) {
338
+ throw new Error(`Federated column mapping creates duplicate output column in ${entry.dataSourceId}: ${outputName}`);
339
+ }
340
+ outputNames.add(outputName);
341
+ return Object.freeze({ sourceName, outputName, field });
342
+ }));
343
+ }
344
+ /** Reconciles mapped fields using strict equality or first-seen union ordering. */
345
+ function reconcileSchemas(sources, schemaPolicy, outputSchema) {
346
+ const outputFields = new Map();
347
+ const firstSourceNames = sources[0].fields.map(field => field.outputName);
348
+ for (const source of sources) {
349
+ const sourceNames = new Set(source.fields.map(field => field.outputName));
350
+ if (schemaPolicy === 'strict') {
351
+ const missing = firstSourceNames.filter(name => !sourceNames.has(name));
352
+ const extra = source.fields
353
+ .map(field => field.outputName)
354
+ .filter(name => !firstSourceNames.includes(name));
355
+ if (missing.length || extra.length) {
356
+ throw new Error(`Federated strict schema mismatch for ${source.entry.dataSourceId}: missing [${missing.join(', ')}], extra [${extra.join(', ')}]`);
357
+ }
358
+ }
359
+ for (const mappedField of source.fields) {
360
+ const current = outputFields.get(mappedField.outputName);
361
+ if (current) {
362
+ if (!outputSchema && !areDataTypesEqual(current.type, mappedField.field.type)) {
363
+ throw new Error(`Federated column type mismatch for ${mappedField.outputName}: ${formatDataType(current.type)} versus ${formatDataType(mappedField.field.type)} in ${source.entry.dataSourceId}`);
364
+ }
365
+ current.nullable = Boolean(current.nullable || mappedField.field.nullable);
366
+ current.presentCount++;
367
+ }
368
+ else {
369
+ outputFields.set(mappedField.outputName, {
370
+ ...mappedField.field,
371
+ name: mappedField.outputName,
372
+ metadata: mappedField.field.metadata ? { ...mappedField.field.metadata } : undefined,
373
+ presentCount: 1
374
+ });
375
+ }
376
+ }
377
+ }
378
+ const fields = [...outputFields.values()].map(({ presentCount, ...field }) => ({
379
+ ...field,
380
+ nullable: Boolean(field.nullable || presentCount < sources.length)
381
+ }));
382
+ const inferredSchema = { fields, metadata: {} };
383
+ return outputSchema
384
+ ? validateAndCloneOutputSchema(outputSchema, sources, inferredSchema, schemaPolicy)
385
+ : inferredSchema;
386
+ }
387
+ /** Validates an explicit output contract and permits only deterministic lossless casts. */
388
+ function validateAndCloneOutputSchema(outputSchema, sources, inferredSchema, schemaPolicy) {
389
+ const outputFields = new Map();
390
+ for (const field of outputSchema.fields) {
391
+ if (!field.name || outputFields.has(field.name)) {
392
+ throw new Error(`Federated output schema contains an invalid or duplicate field: ${field.name}`);
393
+ }
394
+ outputFields.set(field.name, field);
395
+ }
396
+ const inferredNames = new Set(inferredSchema.fields.map(field => field.name));
397
+ const missingFromContract = [...inferredNames].filter(name => !outputFields.has(name));
398
+ const absentFromSources = outputSchema.fields
399
+ .map(field => field.name)
400
+ .filter(name => !inferredNames.has(name));
401
+ if (missingFromContract.length || (schemaPolicy === 'strict' && absentFromSources.length)) {
402
+ throw new Error(`Federated output schema mismatch: missing [${missingFromContract.join(', ')}], absent [${absentFromSources.join(', ')}]`);
403
+ }
404
+ for (const outputField of outputSchema.fields) {
405
+ const presentFields = sources.flatMap(source => source.fields
406
+ .filter(field => field.outputName === outputField.name)
407
+ .map(field => ({ sourceId: source.entry.dataSourceId, field: field.field })));
408
+ if (!presentFields.length) {
409
+ if (!outputField.nullable) {
410
+ throw new Error(`Federated output field is absent from every source and must be nullable: ${outputField.name}`);
411
+ }
412
+ continue;
413
+ }
414
+ for (const presentField of presentFields) {
415
+ if (presentField.field.nullable && !outputField.nullable) {
416
+ throw new Error(`Federated output field cannot remove nullability for ${outputField.name} in ${presentField.sourceId}`);
417
+ }
418
+ if (!isLosslessDataTypeCast(presentField.field.type, outputField.type)) {
419
+ throw new Error(`Unsupported federated normalization for ${outputField.name}: ${formatDataType(presentField.field.type)} to ${formatDataType(outputField.type)} in ${presentField.sourceId}`);
420
+ }
421
+ }
422
+ if (presentFields.length < sources.length && !outputField.nullable) {
423
+ throw new Error(`Federated union field must be nullable when absent from a source: ${outputField.name}`);
424
+ }
425
+ }
426
+ return cloneSchema(outputSchema);
427
+ }
428
+ /** Creates the source-local query required by the global canonical scan step. */
429
+ function createSourceReadOptions(source, requiredOutputColumns, remaining, options, onTelemetry) {
430
+ const sourceResidualPredicate = getSourceResidualPredicate(source);
431
+ const requiredSourceColumns = requiredOutputColumns
432
+ .map(outputName => source.sourceNameByOutputName.get(outputName))
433
+ .filter((sourceName) => Boolean(sourceName));
434
+ if (sourceResidualPredicate) {
435
+ requiredSourceColumns.push(...getColumnarPredicateColumns(sourceResidualPredicate));
436
+ }
437
+ if (!requiredSourceColumns.length) {
438
+ requiredSourceColumns.push(source.fields[0].sourceName);
439
+ }
440
+ const sourceLimit = source.entry.query?.limit;
441
+ const globalLimitCanBePushed = options.predicate === undefined && Number.isFinite(remaining);
442
+ const limit = globalLimitCanBePushed
443
+ ? Math.min(sourceLimit ?? Number.POSITIVE_INFINITY, remaining)
444
+ : sourceLimit;
445
+ return {
446
+ ...source.entry.query,
447
+ columns: Object.freeze([...new Set(requiredSourceColumns)]),
448
+ predicate: sourceResidualPredicate ? undefined : source.entry.query?.predicate,
449
+ limit: sourceResidualPredicate ? undefined : Number.isFinite(limit) ? limit : undefined,
450
+ signal: options.signal,
451
+ onTelemetry
452
+ };
453
+ }
454
+ /** Returns the source-local predicate when the child requires residual execution. */
455
+ function getSourceResidualPredicate(source) {
456
+ return source.metadata.capabilities.table?.predicate === 'unsupported'
457
+ ? source.entry.query?.predicate
458
+ : undefined;
459
+ }
460
+ /** Converts one physical batch to the reconciled Arrow schema, renaming and null-filling fields. */
461
+ function createCanonicalArrowTable(schema, source, physicalTable) {
462
+ const arrowSchema = convertSchemaToArrow(schema);
463
+ const columns = {};
464
+ for (const field of arrowSchema.fields) {
465
+ const sourceName = source.sourceNameByOutputName.get(field.name);
466
+ const vector = sourceName ? physicalTable.data.getChild(sourceName) : null;
467
+ const mappedField = source.fields.find(sourceField => sourceField.outputName === field.name);
468
+ columns[field.name] = vector
469
+ ? mappedField &&
470
+ !areDataTypesEqual(mappedField.field.type, schema.fields.find(schemaField => schemaField.name === field.name).type)
471
+ ? arrow.vectorFromArray(Array.from(vector), field.type)
472
+ : vector
473
+ : arrow.vectorFromArray(Array.from({ length: physicalTable.data.numRows }, () => null), field.type);
474
+ }
475
+ return {
476
+ shape: 'arrow-table',
477
+ schema,
478
+ data: new arrow.Table(arrowSchema, columns)
479
+ };
480
+ }
481
+ /** Creates one portable per-source telemetry record and preserves source-specific counters. */
482
+ function createSourceTelemetry(options) {
483
+ const { resolvedSource, childTelemetry } = options;
484
+ const status = childTelemetry?.status ||
485
+ (options.signal?.aborted
486
+ ? 'cancelled'
487
+ : options.sourceError
488
+ ? 'failed'
489
+ : options.sourceCompleted
490
+ ? 'completed'
491
+ : 'early-terminated');
492
+ const details = childTelemetry
493
+ ? Object.freeze(Object.fromEntries(Object.entries(childTelemetry).filter(([key]) => ![
494
+ 'status',
495
+ 'sourcesPlanned',
496
+ 'sourcesRead',
497
+ 'batchesRead',
498
+ 'batchesDecoded',
499
+ 'rowsRead',
500
+ 'rowsTested',
501
+ 'rowsRetained',
502
+ 'rowsReturned',
503
+ 'bytesRead',
504
+ 'bytesFetched',
505
+ 'filesOpened',
506
+ 'tasksOpened',
507
+ 'rowsPruned',
508
+ 'durationMilliseconds',
509
+ 'sources',
510
+ 'details',
511
+ 'error'
512
+ ].includes(key))))
513
+ : undefined;
514
+ return Object.freeze({
515
+ sourceId: resolvedSource.entry.dataSourceId,
516
+ sourceType: resolvedSource.metadata.sourceType,
517
+ sourceIndex: resolvedSource.sourceIndex,
518
+ status,
519
+ filesOpened: childTelemetry?.filesOpened,
520
+ tasksOpened: childTelemetry?.tasksOpened,
521
+ bytesFetched: childTelemetry?.bytesFetched ?? childTelemetry?.bytesRead,
522
+ batchesDecoded: childTelemetry?.batchesDecoded ?? options.batchesDecoded,
523
+ rowsRead: childTelemetry?.rowsRead ?? options.rowsRead,
524
+ rowsTested: childTelemetry?.rowsTested || options.rowsTested || undefined,
525
+ rowsRetained: childTelemetry?.rowsRetained || options.rowsRetained || undefined,
526
+ rowsReturned: options.rowsReturned,
527
+ rowsPruned: childTelemetry?.rowsPruned,
528
+ durationMilliseconds: Date.now() - options.sourceStartedAt,
529
+ details: childTelemetry?.details ?? (details && Object.keys(details).length ? details : undefined),
530
+ error: childTelemetry?.error ?? options.sourceError
531
+ });
532
+ }
533
+ /** Returns exact aggregate row count only when child-local filters and limits cannot change it. */
534
+ function getFederatedStatistics(sources) {
535
+ if (sources.some(source => source.entry.query?.predicate ||
536
+ source.entry.query?.limit !== undefined ||
537
+ source.metadata.statistics?.rowCount === undefined)) {
538
+ return undefined;
539
+ }
540
+ const counts = sources.map(source => source.metadata.statistics.rowCount);
541
+ return {
542
+ rowCount: counts.some(count => typeof count === 'bigint')
543
+ ? counts.reduce((sum, count) => sum + BigInt(count), 0n)
544
+ : counts.reduce((sum, count) => sum + Number(count), 0)
545
+ };
546
+ }
547
+ /** Creates serializable per-source explanation details. */
548
+ function createSourceExplanation(source, outputSchema) {
549
+ const normalizedTypes = Object.fromEntries(source.fields
550
+ .filter(field => {
551
+ const outputField = outputSchema.fields.find(schemaField => schemaField.name === field.outputName);
552
+ return outputField && !areDataTypesEqual(outputField.type, field.field.type);
553
+ })
554
+ .map(field => [
555
+ field.outputName,
556
+ outputSchema.fields.find(schemaField => schemaField.name === field.outputName).type
557
+ ]));
558
+ return Object.freeze({
559
+ sourceId: source.entry.dataSourceId,
560
+ sourceIndex: source.sourceIndex,
561
+ sourceType: source.metadata.sourceType,
562
+ sourceColumns: Object.freeze(source.fields.map(field => field.sourceName)),
563
+ outputColumns: Object.freeze(source.fields.map(field => field.outputName)),
564
+ columnMapping: Object.freeze({ ...source.entry.columnMapping }),
565
+ normalizedTypes: Object.freeze(normalizedTypes)
566
+ });
567
+ }
568
+ /** Returns true when Arrow can normalize values without narrowing or changing their domain. */
569
+ function isLosslessDataTypeCast(sourceType, outputType) {
570
+ if (areDataTypesEqual(sourceType, outputType) || sourceType === 'null')
571
+ return true;
572
+ if (typeof sourceType === 'object' && sourceType.type === 'dictionary') {
573
+ return isLosslessDataTypeCast(sourceType.dictionary, outputType);
574
+ }
575
+ if (sourceType === 'utf8-view' && outputType === 'utf8')
576
+ return true;
577
+ if (sourceType === 'binary-view' && outputType === 'binary')
578
+ return true;
579
+ const sourceInteger = getIntegerType(sourceType);
580
+ const outputInteger = getIntegerType(outputType);
581
+ if (sourceInteger && outputInteger) {
582
+ return ((sourceInteger.signed === outputInteger.signed && sourceInteger.bits <= outputInteger.bits) ||
583
+ (!sourceInteger.signed && outputInteger.signed && sourceInteger.bits < outputInteger.bits));
584
+ }
585
+ const sourceFloatBits = getFloatBits(sourceType);
586
+ const outputFloatBits = getFloatBits(outputType);
587
+ if (sourceFloatBits && outputFloatBits)
588
+ return sourceFloatBits <= outputFloatBits;
589
+ return Boolean(sourceInteger && sourceInteger.bits <= 32 && outputFloatBits === 64);
590
+ }
591
+ /** Returns the signedness and width for portable integer aliases. */
592
+ function getIntegerType(dataType) {
593
+ const types = {
594
+ int: { signed: true, bits: 32 },
595
+ int8: { signed: true, bits: 8 },
596
+ int16: { signed: true, bits: 16 },
597
+ int32: { signed: true, bits: 32 },
598
+ int64: { signed: true, bits: 64 },
599
+ uint8: { signed: false, bits: 8 },
600
+ uint16: { signed: false, bits: 16 },
601
+ uint32: { signed: false, bits: 32 },
602
+ uint64: { signed: false, bits: 64 }
603
+ };
604
+ return typeof dataType === 'string' ? types[dataType] || null : null;
605
+ }
606
+ /** Returns the width for portable floating-point aliases. */
607
+ function getFloatBits(dataType) {
608
+ const types = { float: 32, float16: 16, float32: 32, float64: 64 };
609
+ return typeof dataType === 'string' ? types[dataType] || null : null;
610
+ }
611
+ /** Clones a portable schema through the canonical Arrow serializer. */
612
+ function cloneSchema(schema) {
613
+ return convertArrowToSchema(convertSchemaToArrow(schema));
614
+ }
615
+ /** Compares portable data types, including nested types and typed union identifiers. */
616
+ function areDataTypesEqual(left, right) {
617
+ return formatDataType(left) === formatDataType(right);
618
+ }
619
+ /** Serializes a portable data type for diagnostics and deterministic equality checks. */
620
+ function formatDataType(dataType) {
621
+ return typeof dataType === 'string' ? dataType : JSON.stringify(canonicalizeValue(dataType));
622
+ }
623
+ /** Canonicalizes object keys while preserving array and typed-array element order. */
624
+ function canonicalizeValue(value) {
625
+ if (ArrayBuffer.isView(value)) {
626
+ return Array.from(value);
627
+ }
628
+ if (Array.isArray(value)) {
629
+ return value.map(canonicalizeValue);
630
+ }
631
+ if (value && typeof value === 'object') {
632
+ return Object.fromEntries(Object.entries(value)
633
+ .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
634
+ .map(([key, child]) => [key, canonicalizeValue(child)]));
635
+ }
636
+ return value;
637
+ }
638
+ /** Resolves a current, asynchronous, or deferred source through one manager subscription. */
639
+ function resolveManagedSource(dataSourceManager, dataSourceId, consumerId, requestId, signal) {
640
+ let resolveDeferredSource;
641
+ let rejectDeferredSource;
642
+ const deferredSource = new Promise((resolve, reject) => {
643
+ resolveDeferredSource = resolve;
644
+ rejectDeferredSource = reject;
645
+ });
646
+ let sourceGeneration = 0;
647
+ const acceptSource = (source) => {
648
+ const generation = ++sourceGeneration;
649
+ if (source) {
650
+ void Promise.resolve(source).then(result => {
651
+ if (generation === sourceGeneration)
652
+ resolveDeferredSource(result);
653
+ }, error => {
654
+ if (generation === sourceGeneration)
655
+ rejectDeferredSource(error);
656
+ });
657
+ }
658
+ };
659
+ const managedSource = dataSourceManager.subscribe({
660
+ dataSourceId,
661
+ consumerId,
662
+ requestId,
663
+ onChange: acceptSource
664
+ });
665
+ if (managedSource === undefined) {
666
+ throw new Error(`Federated table source is not registered: ${dataSourceId}`);
667
+ }
668
+ acceptSource(managedSource || null);
669
+ return waitForPromise(deferredSource, signal);
670
+ }
671
+ /** Waits for a managed asynchronous source while preserving operation-local cancellation. */
672
+ async function waitForPromise(promise, signal) {
673
+ if (!signal)
674
+ return await promise;
675
+ throwIfAborted(signal);
676
+ return await new Promise((resolve, reject) => {
677
+ const abort = () => reject(getAbortReason(signal));
678
+ signal.addEventListener('abort', abort, { once: true });
679
+ void promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
680
+ });
681
+ }
682
+ /** Throws the caller's abort reason between resolution, planning, and batch operations. */
683
+ function throwIfAborted(signal) {
684
+ if (signal?.aborted)
685
+ throw getAbortReason(signal);
686
+ }
687
+ /** Returns a portable cancellation reason when AbortSignal.reason is unavailable. */
688
+ function getAbortReason(signal) {
689
+ return signal.reason || new DOMException('Request aborted', 'AbortError');
690
+ }
691
+ //# sourceMappingURL=federated-table-scan-source.js.map