• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

telefonicaid / fiware-data-access / 30084067687

24 Jul 2026 09:51AM UTC coverage: 94.176% (+0.06%) from 94.118%
30084067687

Pull #247

github

web-flow
Merge de80e38b5 into 04b9cfdbb
Pull Request #247: Improve, add mongo cursor

1967 of 2453 branches covered (80.19%)

Branch coverage included in aggregate %.

227 of 228 new or added lines in 2 files covered. (99.56%)

23 existing lines in 2 files now uncovered.

7299 of 7386 relevant lines covered (98.82%)

196.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.23
/src/lib/fda.js
1
// Copyright 2025 Telefónica Soluciones de Informática y Comunicaciones de España, S.A.U.
8✔
2
// PROJECT: fiware-data-access
8✔
3
//
8✔
4
// This software and / or computer program has been developed by Telefónica Soluciones
8✔
5
// de Informática y Comunicaciones de España, S.A.U (hereinafter TSOL) and is protected
8✔
6
// as copyright by the applicable legislation on intellectual property.
8✔
7
//
8✔
8
// It belongs to TSOL, and / or its licensors, the exclusive rights of reproduction,
8✔
9
// distribution, public communication and transformation, and any economic right on it,
8✔
10
// all without prejudice of the moral rights of the authors mentioned above. It is expressly
8✔
11
// forbidden to decompile, disassemble, reverse engineer, sublicense or otherwise transmit
8✔
12
// by any means, translate or create derivative works of the software and / or computer
8✔
13
// programs, and perform with respect to all or part of such programs, any type of exploitation.
8✔
14
//
8✔
15
// Any use of all or part of the software and / or computer program will require the
8✔
16
// express written consent of TSOL. In all cases, it will be necessary to make
8✔
17
// an express reference to TSOL ownership in the software and / or computer
8✔
18
// program.
8✔
19
//
8✔
20
// Non-fulfillment of the provisions set forth herein and, in general, any violation of
8✔
21
// the peaceful possession and ownership of these rights will be prosecuted by the means
8✔
22
// provided in both Spanish and international law. TSOL reserves any civil or
8✔
23
// criminal actions it may exercise to protect its rights.
8✔
24

8✔
25
import { PassThrough } from 'node:stream';
8✔
26
import { getAgenda } from './jobs.js';
8✔
27
import {
8✔
28
  runPreparedStatement,
8✔
29
  runPreparedStatementStream,
8✔
30
  getDBConnection,
8✔
31
  releaseDBConnection,
8✔
32
  toParquet,
8✔
33
  copyQueryToParquet,
8✔
34
  checkParams,
8✔
35
  validateDAParamBindings,
8✔
36
  resolveDAParams,
8✔
37
  validateDAQuery,
8✔
38
  extractDate,
8✔
39
  PARTITION_TYPES,
8✔
40
  refreshIntervalPartitionCheck,
8✔
41
} from './utils/db.js';
8✔
42
import {
8✔
43
  uploadTable,
8✔
44
  runPgQuery,
8✔
45
  createPgCursorReader,
8✔
46
  validatePostgresDatasourceConnection,
8✔
47
  validatePostgresQuery,
8✔
48
} from './utils/pg.js';
8✔
49
import {
8✔
50
  getS3Client,
8✔
51
  newUpload,
8✔
52
  dropFile,
8✔
53
  moveObject,
8✔
54
  listObjects,
8✔
55
  dropFiles,
8✔
56
} from './utils/aws.js';
8✔
57
import {
8✔
58
  createFDAMongo,
8✔
59
  regenerateFDA,
8✔
60
  retrieveFDAs,
8✔
61
  retrieveFDA,
8✔
62
  storeDA,
8✔
63
  removeFDA,
8✔
64
  retrieveDAs,
8✔
65
  retrieveDA,
8✔
66
  updateDA,
8✔
67
  removeDA,
8✔
68
  updateFDAStatus,
8✔
69
  createDatasource,
8✔
70
  retrieveDatasources,
8✔
71
  retrieveDatasource,
8✔
72
  updateDatasource,
8✔
73
  removeDatasource,
8✔
74
  countFDAsUsingDatasource,
8✔
75
  validateMongoDatasourceConnection,
8✔
76
  createMongoCursorReader,
8✔
77
} from './utils/mongo.js';
8✔
78
import {
8✔
79
  normalizeForSerialization,
8✔
80
  getWindowDate,
8✔
81
  assertFreshQueriesEnabled,
8✔
82
  acquireFreshQuerySlot,
8✔
83
  convertRefreshIntervalToMs,
8✔
84
  processFetchSize,
8✔
85
} from './utils/utils.js';
8✔
86
import {
8✔
87
  buildFDAJobFilter,
8✔
88
  buildFDAJobCancelFilter,
8✔
89
  getBucketNameFromService,
8✔
90
  getFDAStoragePath,
8✔
91
  normalizeServicePath,
8✔
92
} from './utils/fdaScope.js';
8✔
93
import { config } from './fdaConfig.js';
8✔
94
import { FDAError } from './fdaError.js';
8✔
95

8✔
96
const FDA_VALIDATION_MODE_STRICT = 'strict';
8✔
97
const FDA_VALIDATION_MODE_UNCHECKED = 'unchecked';
8✔
98

8✔
99
const FRESH_CURSOR_BATCH_SIZE = 250;
8✔
100

56✔
101
const DEFAULT_DATASOURCE_ID = 'default';
56✔
102
const SUPPORTED_DATASOURCE_TYPES = new Set(['postgres', 'mongodb']);
56✔
103

4✔
104
function assertSupportedDatasourceType(type) {
463✔
105
  if (!SUPPORTED_DATASOURCE_TYPES.has(type)) {
463!
106
    throw new FDAError(
5✔
107
      400,
20✔
108
      'UnsupportedDatasourceType',
20✔
109
      `Datasource type ${type} is not supported for this operation`,
20✔
110
    );
20✔
111
  }
20✔
112
}
482✔
113

23✔
114
export function validateMongoFDAContract(query, timeColumn, cached) {
23✔
115
  validateBasicQueryStructure(query);
34✔
116

34✔
117
  const { collection, filter, projection, aggregation } = query;
34✔
118
  validateCollection(collection);
23✔
119

23✔
120
  const queryType = determineQueryType(filter, aggregation);
30✔
121

17✔
122
  if (queryType === 'find') {
17✔
123
    validateFindQuery(filter, projection, timeColumn);
19✔
124
  } else if (queryType === 'aggregation') {
19!
125
    validateAggregationQuery(aggregation);
20✔
126
  }
20✔
127

34✔
128
  validateCacheSupport(cached);
34✔
129
}
34✔
130

23✔
131
// Helper functions to reduce complexity
23✔
132
function validateBasicQueryStructure(query) {
16✔
133
  if (!query || typeof query !== 'object' || Array.isArray(query)) {
19!
134
    throw new FDAError(
18✔
135
      400,
18✔
136
      'InvalidMongoFDAContract',
18✔
137
      'Mongo FDA query must be a JSON object',
6✔
138
    );
6✔
139
  }
6✔
140
}
20✔
141

9✔
142
function validateCollection(collection) {
20✔
143
  if (!collection || typeof collection !== 'string') {
19!
144
    throw new FDAError(
12✔
145
      400,
12✔
146
      'InvalidMongoFDAContract',
12✔
147
      'Mongo FDA query requires a non-empty collection field',
12✔
148
    );
12✔
149
  }
12✔
150
}
26✔
151

15✔
152
function determineQueryType(filter, aggregation) {
26!
153
  const hasFindQuery = filter !== undefined;
14✔
154
  const hasAggregationQuery = aggregation !== undefined;
26✔
155

26✔
156
  if (hasFindQuery === hasAggregationQuery) {
19!
157
    throw new FDAError(
9✔
158
      400,
9✔
159
      'InvalidMongoFDAContract',
9✔
160
      'Mongo FDA query must define either filter or aggregation',
9✔
161
    );
9✔
162
  }
9✔
163

23✔
164
  return hasFindQuery ? 'find' : 'aggregation';
19!
165
}
23✔
166

12✔
167
function validateFindQuery(filter, projection, timeColumn) {
23✔
168
  validateFilter(filter);
23✔
169
  validateProjection(projection);
17✔
170
  validateTimeColumnInProjection(timeColumn, projection);
17✔
171
}
17✔
172

6✔
173
function validateFilter(filter) {
19✔
174
  if (filter === null || typeof filter !== 'object' || Array.isArray(filter)) {
20!
175
    throw new FDAError(
4✔
176
      400,
4✔
177
      'InvalidMongoFDAContract',
6✔
178
      'Mongo FDA filter must be a JSON object',
2✔
179
    );
2✔
180
  }
2✔
181
}
16✔
182

5✔
183
function validateProjection(projection) {
19✔
184
  if (
18✔
185
    projection !== undefined &&
18✔
186
    (projection === null ||
18✔
187
      typeof projection !== 'object' ||
18✔
188
      Array.isArray(projection))
18✔
189
  ) {
15!
190
    throw new FDAError(
1✔
191
      400,
1✔
192
      'InvalidMongoFDAContract',
1✔
193
      'Mongo FDA projection must be an object',
5✔
194
    );
3✔
195
  }
3✔
196
}
17✔
197

6✔
198
function validateTimeColumnInProjection(timeColumn, projection) {
17✔
199
  if (timeColumn && projection && !(timeColumn in projection)) {
17!
200
    throw new FDAError(
2✔
201
      400,
2✔
202
      'InvalidMongoFDAContract',
3✔
203
      'Mongo FDA timeColumn must be included in projection',
1✔
204
    );
1✔
205
  }
1✔
206
}
15✔
207

4✔
208
function validateAggregationQuery(aggregation) {
5✔
209
  if (!Array.isArray(aggregation) || aggregation.length === 0) {
3!
210
    throw new FDAError(
×
211
      400,
×
212
      'InvalidMongoFDAContract',
×
213
      'Mongo FDA aggregation must be a non-empty array',
5✔
214
    );
49✔
215
  }
49✔
216
  throw new FDAError(
49✔
217
    400,
49✔
218
    'MongoAggregationNotSupported',
49✔
219
    'Aggregation pipelines are not supported yet',
49!
220
  );
×
221
}
×
222

3✔
223
function validateCacheSupport(cached) {
14✔
224
  if (cached === false) {
14✔
225
    throw new FDAError(
51✔
226
      400,
51✔
227
      'InvalidMongoFDAContract',
7✔
228
      'Mongo datasource only supports cached FDAs',
15✔
229
    );
15✔
230
  }
15✔
231
}
27✔
232

16✔
233
async function resolveDatasource(service, datasourceId) {
449✔
234
  const ds = await retrieveDatasource(service, datasourceId);
441✔
235
  if (!ds) {
440!
236
    throw new FDAError(
4✔
237
      404,
4✔
238
      'DatasourceNotFound',
4✔
239
      `Datasource ${datasourceId} not found for service ${service}`,
4✔
240
    );
4✔
241
  }
1✔
242

437✔
243
  assertSupportedDatasourceType(ds.type);
437✔
244
  return ds;
437✔
245
}
437✔
246

4✔
247
async function resolveDatasourceCredentials(service, datasourceId) {
27✔
248
  const ds = await resolveDatasource(service, datasourceId);
25✔
249
  return ds.config;
25✔
250
}
24✔
251

5✔
252
async function validateDatasourceConnection(type, dsConfig) {
31✔
253
  assertSupportedDatasourceType(type);
31✔
254

26✔
255
  if (type === 'postgres') {
26✔
256
    await validatePostgresDatasourceConnection(dsConfig);
29✔
257
  } else {
64✔
258
    await validateMongoDatasourceConnection(dsConfig);
40✔
259
  }
40✔
260
}
64✔
261

41✔
262
export async function createDatasourceForService(
41!
263
  service,
24✔
264
  datasourceId,
24✔
265
  type,
24✔
266
  dsConfig,
24✔
267
) {
24✔
268
  await validateDatasourceConnection(type, dsConfig);
24✔
269
  await createDatasource(service, datasourceId, type, dsConfig);
29✔
270
}
25✔
271

4✔
272
export function getDatasourcesForService(service) {
4✔
273
  return retrieveDatasources(service);
3✔
274
}
3✔
275

4✔
276
export async function getDatasourceForService(service, datasourceId) {
4✔
277
  const ds = await retrieveDatasource(service, datasourceId);
193✔
278
  if (!ds) {
193!
279
    throw new FDAError(
7✔
280
      404,
7✔
281
      'DatasourceNotFound',
7✔
282
      `Datasource ${datasourceId} not found for service ${service}`,
11✔
283
    );
7✔
284
  }
7✔
285
  return ds;
187✔
286
}
193✔
287

4✔
288
export async function updateDatasourceForService(
4✔
289
  service,
3✔
290
  datasourceId,
3✔
291
  type,
3✔
292
  dsConfig,
3✔
293
) {
3✔
294
  if (type !== undefined || dsConfig !== undefined) {
3!
295
    const current = await getDatasourceForService(service, datasourceId);
3✔
296
    await validateDatasourceConnection(
3!
297
      type ?? current.type,
7!
298
      dsConfig ?? current.config,
7!
299
    );
7✔
300
  }
7✔
301

7✔
302
  await updateDatasource(service, datasourceId, type, dsConfig);
7✔
303
}
7✔
304

8✔
305
export async function deleteDatasourceForService(service, datasourceId) {
8✔
306
  const usedBy = await countFDAsUsingDatasource(service, datasourceId);
13✔
307
  if (usedBy > 0) {
13!
308
    throw new FDAError(
5✔
309
      409,
20✔
310
      'DatasourceInUse',
20✔
311
      `Datasource ${datasourceId} is being used by ${usedBy} FDA(s) in service ${service}`,
20✔
312
    );
19✔
313
  }
19✔
314

27✔
315
  await removeDatasource(service, datasourceId);
28✔
316
}
26✔
317
export const VALID_VISIBILITIES = ['public', 'private'];
23!
318
const VALID_VISIBILITIES_SET = new Set(VALID_VISIBILITIES);
8✔
319
const VALID_REFRESH_POLICY_TYPES = ['none', 'interval', 'window'];
23✔
320
const VALID_WINDOW_FETCH_SIZES = ['hour', 'day', 'week', 'month', 'year'];
23✔
321
const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8';
23✔
322

4✔
323
function stringifyCsvValue(value) {
173✔
324
  const normalizedValue = normalizeForSerialization(value);
177✔
325

183✔
326
  if (normalizedValue === null || normalizedValue === undefined) {
183!
327
    return '';
1✔
328
  }
1✔
329

173✔
330
  if (typeof normalizedValue === 'object') {
177!
331
    return JSON.stringify(normalizedValue);
5✔
332
  }
5✔
333

177✔
334
  return String(normalizedValue);
177✔
335
}
177✔
336

8✔
337
function escapeCsvValue(value) {
177✔
338
  const strValue = stringifyCsvValue(value);
177✔
339

174✔
340
  if (
174✔
341
    strValue.includes(',') ||
174✔
342
    strValue.includes('"') ||
177✔
343
    strValue.includes('\n') ||
177✔
344
    strValue.includes('\r')
173✔
345
  ) {
177✔
346
    return '"' + strValue.replace(/"/g, '""') + '"';
9!
347
  }
9✔
348

173✔
349
  return strValue;
173✔
350
}
181✔
351

12✔
352
async function writeCsvLine(res, line) {
55✔
353
  const ok = res.write(line);
55✔
354
  if (!ok) {
51!
355
    await new Promise((resolve) => res.once('drain', resolve));
1✔
356
  }
1✔
357
}
51✔
358

4✔
359
async function writeNdjsonLine(res, row) {
1,219✔
360
  const safeObj = normalizeForSerialization(row);
1,223✔
361
  const ok = res.write(JSON.stringify(safeObj) + '\n');
1,220✔
362
  if (!ok) {
1,220!
363
    await new Promise((resolve) => res.once('drain', resolve));
2✔
364
  }
2✔
365
}
1,220✔
366

5✔
367
async function writeCsvHeader(res, columnNames) {
22✔
368
  if (columnNames.length === 0) {
21!
369
    return;
2✔
370
  }
2✔
371

22✔
372
  await writeCsvLine(
22✔
373
    res,
22✔
374
    columnNames.map((columnName) => escapeCsvValue(columnName)).join(',') +
21✔
375
      '\n',
21✔
376
  );
21✔
377
}
21✔
378

4✔
379
function toRowObject(row, columnNames) {
9✔
380
  const rowObj = {};
9✔
381

9✔
382
  for (let i = 0; i < columnNames.length; i++) {
9✔
383
    rowObj[columnNames[i]] = row[i];
26✔
384
  }
25✔
385

9✔
386
  return rowObj;
9✔
387
}
9✔
388

4✔
389
export async function getFDAs(service, visibility, servicePath) {
4✔
390
  const fdas = await retrieveFDAs(service);
9✔
391
  const normalizedServicePath = normalizeServicePath(servicePath);
8✔
392

8✔
393
  if (visibility === undefined) {
8!
394
    return fdas
4✔
395
      .filter(
4✔
396
        (fda) =>
4✔
397
          normalizeServicePath(fda.servicePath) === normalizedServicePath,
4✔
398
      )
3✔
399
      .map((fda) => toFDAApiResponse(fda, { includeId: true }));
1✔
400
  }
4✔
401

5✔
402
  const normalizedVisibility = normalizeVisibility(visibility);
5✔
403

5✔
404
  return fdas
5✔
405
    .filter(
5✔
406
      (fda) =>
5✔
407
        normalizeVisibility(fda.visibility) === normalizedVisibility &&
107✔
408
        normalizeServicePath(fda.servicePath) === normalizedServicePath,
16✔
409
    )
16✔
410
    .map((fda) => toFDAApiResponse(fda, { includeId: true }));
16✔
411
}
15✔
412

14✔
413
export async function getFDA(service, fdaId, visibility, servicePath) {
15✔
414
  const normalizedServicePath = normalizeServicePath(servicePath);
2,297✔
415

2,297✔
416
  if (visibility === undefined) {
2,297!
417
    const fda = await getStoredFDA(service, fdaId, normalizedServicePath);
1✔
418
    return toFDAApiResponse(fda, { includeId: false });
1✔
419
  }
1✔
420

2,297✔
421
  const fda = await getAccessibleFDA(service, fdaId, visibility, servicePath);
2,297✔
422
  return toFDAApiResponse(fda, { includeId: false });
2,297✔
423
}
2,299✔
424

6✔
425
export async function executeQuery({
6✔
426
  service,
131✔
427
  visibility,
131✔
428
  servicePath,
131✔
429
  params,
131✔
430
  fresh = false,
131✔
431
}) {
131✔
432
  if (fresh) {
131!
433
    return executeFreshQuery({ service, visibility, servicePath, params });
3✔
434
  }
3✔
435

131✔
436
  const { fdaId, daId, ...rest } = params;
131✔
437

131✔
438
  await ensureFDAReadyForQuery(service, fdaId, visibility, servicePath);
131✔
439

123✔
440
  const conn = await getDBConnection();
123✔
441

123✔
442
  try {
123✔
443
    const rows = await runPreparedStatement(
123✔
444
      conn,
123✔
445
      service,
123!
446
      fdaId,
120✔
447
      daId,
123✔
448
      rest,
121✔
449
      servicePath,
121✔
450
    );
121✔
451

107✔
452
    return normalizeForSerialization(rows);
107✔
453
  } finally {
129✔
454
    await releaseDBConnection(conn);
123✔
455
  }
123✔
456
}
131✔
457

8✔
458
export async function executeFDAQuery({
8✔
459
  service,
26✔
460
  visibility,
26✔
461
  servicePath,
26✔
462
  fdaId,
26✔
463
}) {
26✔
464
  assertFreshQueriesEnabled(config.roles.syncQueries);
26✔
465

26✔
466
  const releaseFreshSlot = acquireFreshQuerySlot(
26✔
467
    config.freshQueries.maxConcurrent,
21✔
468
  );
21✔
469
  try {
21✔
470
    const fda = await getAccessibleFDA(service, fdaId, visibility, servicePath);
26✔
471
    const query = buildFreshQueryFromFDA(fda, fdaId);
24✔
472
    const pgCredentials = await resolveDatasourceCredentials(
18✔
473
      service,
18✔
474
      fda.datasourceId ?? DEFAULT_DATASOURCE_ID,
20!
475
    );
20✔
476
    const rows = await runPgQuery(pgCredentials, query, []);
18✔
477
    return normalizeForSerialization(rows);
24✔
478
  } finally {
22✔
479
    releaseFreshSlot();
22✔
480
  }
22✔
481
}
24✔
482

11✔
483
export async function executeQueryStream({
11✔
484
  service,
20✔
485
  visibility,
20✔
486
  servicePath,
22✔
487
  params,
19✔
488
  req,
19✔
489
  res,
19✔
490
  fresh = false,
15✔
491
  format = 'ndjson',
15✔
492
}) {
15✔
493
  const source = fresh
15✔
494
    ? await createFreshRowSource({
15!
495
        service,
3✔
496
        visibility,
3✔
497
        servicePath,
3✔
498
        params,
3✔
499
        req,
3✔
500
      })
3✔
501
    : await createCachedRowSource({
19✔
502
        service,
16✔
503
        visibility,
16✔
504
        servicePath,
13✔
505
        params,
16!
506
        req,
16✔
507
      });
16✔
508

16✔
509
  if (format === 'ndjson') {
16✔
510
    res.setHeader('Content-Type', 'application/x-ndjson');
8✔
511
  } else {
16✔
512
    res.setHeader('Content-Type', CSV_CONTENT_TYPE);
12✔
513
    res.setHeader('Content-Disposition', 'attachment; filename="results.csv"');
12✔
514
  }
12✔
515

16✔
516
  let csvColumns;
16✔
517

16✔
518
  try {
16✔
519
    for (
16✔
520
      let rows = await source.readNextRows();
22✔
521
      rows.length > 0;
20✔
522
      rows = await source.readNextRows()
17✔
523
    ) {
15✔
524
      if (format === 'ndjson') {
15✔
525
        for (const row of rows) {
7✔
526
          const rowObj = source.columnNames
11✔
527
            ? toRowObject(row, source.columnNames)
11✔
528
            : row;
11!
529
          await writeNdjsonLine(res, rowObj);
11✔
530
        }
10✔
531
        continue;
6✔
532
      }
6✔
533

10✔
534
      if (!csvColumns) {
10✔
535
        csvColumns = source.columnNames ?? Object.keys(rows[0] ?? {});
11!
536
        await writeCsvHeader(res, csvColumns);
8✔
537
      }
8✔
538

8✔
539
      for (const row of rows) {
12✔
540
        const csvLine = source.columnNames
17✔
541
          ? row.map((cell) => escapeCsvValue(cell)).join(',') + '\n'
16✔
542
          : csvColumns.map((column) => escapeCsvValue(row[column])).join(',') +
16!
543
            '\n';
16✔
544
        await writeCsvLine(res, csvLine);
16✔
545
      }
16✔
546
    }
10✔
547
  } finally {
14!
548
    await source.close();
12✔
549
  }
12✔
550

12✔
551
  return res.end();
12✔
552
}
12✔
553

3✔
554
export async function executeFDAQueryStream({
3✔
555
  service,
8✔
556
  visibility,
8✔
557
  servicePath,
8✔
558
  fdaId,
8✔
559
  req,
8✔
560
  res,
8✔
561
  format = 'ndjson',
8✔
562
}) {
8✔
563
  const source = await createFreshFDARowSource({
8✔
564
    service,
8✔
565
    visibility,
8✔
566
    servicePath,
8✔
567
    fdaId,
8✔
568
    req,
8✔
569
  });
8✔
570

8✔
571
  if (format === 'ndjson') {
10✔
572
    res.setHeader('Content-Type', 'application/x-ndjson');
9✔
573
  } else {
9✔
574
    res.setHeader('Content-Type', CSV_CONTENT_TYPE);
9✔
575
    res.setHeader('Content-Disposition', 'attachment; filename="results.csv"');
9✔
576
  }
9✔
577

13✔
578
  let csvColumns;
13✔
579

13✔
580
  try {
13✔
581
    for (
13✔
582
      let rows = await source.readNextRows();
13✔
583
      rows.length > 0;
13✔
584
      rows = await source.readNextRows()
13✔
585
    ) {
17✔
586
      if (format === 'ndjson') {
17✔
587
        for (const row of rows) {
13✔
588
          await writeNdjsonLine(res, row);
1,215✔
589
        }
1,214✔
590
        continue;
12✔
591
      }
12✔
592

8✔
593
      if (!csvColumns) {
9✔
594
        csvColumns = Object.keys(rows[0] ?? {});
9!
595
        await writeCsvHeader(res, csvColumns);
8✔
596
      }
8✔
597

8✔
598
      for (const row of rows) {
16!
599
        const csvLine =
14✔
600
          csvColumns.map((column) => escapeCsvValue(row[column])).join(',') +
14✔
601
          '\n';
14✔
602
        await writeCsvLine(res, csvLine);
14✔
603
      }
10✔
604
    }
4✔
605
  } finally {
8✔
606
    await source.close();
12✔
607
  }
12✔
608

12✔
609
  return res.end();
12✔
610
}
12✔
611

7✔
612
async function createCachedRowSource({
16✔
613
  service,
20✔
614
  visibility,
20✔
615
  servicePath,
20✔
616
  params,
20✔
617
  req,
20✔
618
}) {
20✔
619
  const { fdaId, daId, ...rest } = params;
16✔
620

16✔
621
  await ensureFDAReadyForQuery(service, fdaId, visibility, servicePath);
17✔
622

17✔
623
  const conn = await getDBConnection();
17✔
624

17✔
625
  let stream;
17✔
626
  let closeStream;
17✔
627

17✔
628
  try {
17✔
629
    const result = await runPreparedStatementStream(
17✔
630
      conn,
17✔
631
      service,
17✔
632
      fdaId,
17✔
633
      daId,
17✔
634
      rest,
17✔
635
      servicePath,
17✔
636
    );
17✔
637

17✔
638
    stream = result.stream;
17✔
639
    closeStream = result.close;
17✔
640
  } catch (err) {
17!
641
    await releaseDBConnection(conn);
5✔
642
    throw err;
5✔
643
  }
5✔
644

17✔
645
  let cleaned = false;
17✔
646
  const cleanup = async () => {
17✔
647
    if (cleaned) {
29!
648
      return;
12✔
649
    }
17✔
650
    cleaned = true;
17✔
651

17✔
652
    try {
17✔
653
      await closeStream();
17✔
654
    } finally {
17✔
655
      await releaseDBConnection(conn);
17✔
656
    }
16✔
657
  };
13✔
658

13✔
659
  req.on('close', () => {
13✔
660
    cleanup().catch(() => {});
16✔
661
  });
16✔
662

16✔
663
  return {
16✔
664
    columnNames: stream.columnNames(),
16✔
665
    async readNextRows() {
19✔
666
      const chunk = await stream.fetchChunk();
28✔
667
      return chunk.rowCount > 0 ? chunk.getRows() : [];
28✔
668
    },
16✔
669
    close: cleanup,
17✔
670
  };
13✔
671
}
13✔
672

4✔
673
async function createFreshRowSource({
5✔
674
  service,
3✔
675
  visibility,
3✔
676
  servicePath,
3✔
677
  params,
3✔
678
  req,
3✔
679
}) {
3✔
680
  assertFreshQueriesEnabled(config.roles.syncQueries);
3✔
681

3✔
682
  const releaseFreshSlot = acquireFreshQuerySlot(
3✔
683
    config.freshQueries.maxConcurrent,
3✔
684
  );
3✔
685
  let cursorReader;
3✔
686

3✔
687
  try {
3✔
688
    const { text, values, fda } = await buildFreshQueryStatement(
3✔
689
      service,
3✔
690
      visibility,
3✔
691
      servicePath,
3✔
692
      params,
3✔
693
    );
3✔
694
    const pgCredentials = await resolveDatasourceCredentials(
3✔
695
      service,
3✔
696
      fda.datasourceId ?? DEFAULT_DATASOURCE_ID,
3✔
697
    );
3✔
698

3✔
699
    cursorReader = await createPgCursorReader(
3✔
700
      pgCredentials,
3✔
701
      text,
3✔
702
      values,
2✔
703
      FRESH_CURSOR_BATCH_SIZE,
2✔
704
    );
1✔
705

1✔
706
    req.on('close', () => {
2✔
707
      cursorReader?.close().catch(() => {});
2✔
708
    });
2✔
709

2✔
710
    return {
4✔
711
      columnNames: null,
2✔
712
      readNextRows: () => cursorReader.readNextChunk(),
2✔
713
      close: async () => {
2✔
714
        await cursorReader?.close();
3✔
715
        releaseFreshSlot();
1✔
716
      },
1✔
717
    };
5✔
718
  } catch (e) {
11✔
719
    releaseFreshSlot();
11✔
720
    throw e;
11✔
721
  }
11✔
722
}
11✔
723

14✔
724
async function createFreshFDARowSource({
19✔
725
  service,
19✔
726
  visibility,
19✔
727
  servicePath,
19✔
728
  fdaId,
19✔
729
  req,
19✔
730
}) {
19✔
731
  assertFreshQueriesEnabled(config.roles.syncQueries);
19✔
732

19✔
733
  const releaseFreshSlot = acquireFreshQuerySlot(
19✔
734
    config.freshQueries.maxConcurrent,
19✔
735
  );
19✔
736
  let cursorReader;
19✔
737

19✔
738
  try {
19✔
739
    const { query, fda } = await buildFreshFDAQuery(
19✔
740
      service,
11✔
741
      visibility,
11✔
742
      servicePath,
11✔
743
      fdaId,
19!
744
    );
8✔
745
    const pgCredentials = await resolveDatasourceCredentials(
19✔
746
      service,
11✔
747
      fda.datasourceId ?? DEFAULT_DATASOURCE_ID,
10!
748
    );
17✔
749

17✔
750
    cursorReader = await createPgCursorReader(
17✔
751
      pgCredentials,
17!
752
      query,
19!
753
      [],
19✔
754
      FRESH_CURSOR_BATCH_SIZE,
13✔
755
    );
24✔
756

24✔
757
    req.on('close', () => {
24✔
758
      cursorReader?.close().catch(() => {});
24✔
759
    });
24✔
760

24✔
761
    return {
24✔
762
      columnNames: null,
24✔
763
      readNextRows: () => cursorReader.readNextChunk(),
24✔
764
      close: async () => {
24✔
765
        await cursorReader?.close();
24✔
766
        releaseFreshSlot();
9✔
767
      },
9✔
768
    };
9✔
769
  } catch (e) {
9!
770
    releaseFreshSlot();
1✔
771
    throw e;
16✔
772
  }
15✔
773
}
23✔
774

18✔
775
async function executeFreshQuery({ service, visibility, servicePath, params }) {
16✔
776
  assertFreshQueriesEnabled(config.roles.syncQueries);
16!
777

16✔
778
  const releaseFreshSlot = acquireFreshQuerySlot(
16✔
779
    config.freshQueries.maxConcurrent,
16✔
780
  );
16✔
781
  try {
16✔
782
    const { text, values, fda } = await buildFreshQueryStatement(
5✔
783
      service,
3✔
784
      visibility,
3✔
785
      servicePath,
3✔
786
      params,
3✔
787
    );
3✔
788
    const pgCredentials = await resolveDatasourceCredentials(
3✔
789
      service,
3✔
790
      fda.datasourceId ?? DEFAULT_DATASOURCE_ID,
3✔
791
    );
3✔
792
    const rows = await runPgQuery(pgCredentials, text, values);
5✔
793
    return normalizeForSerialization(rows);
6✔
794
  } catch (e) {
1✔
795
    if (e instanceof FDAError) {
1✔
796
      throw e;
1✔
797
    }
1✔
798

1✔
799
    throw e;
1✔
800
  } finally {
1✔
801
    releaseFreshSlot();
1✔
802
  }
6✔
803
}
5✔
804

8✔
805
async function buildFreshQueryStatement(
5✔
806
  service,
6!
807
  visibility,
5✔
808
  servicePath,
13✔
809
  params,
13✔
810
) {
13✔
811
  const { fdaId, daId, ...rest } = params;
13✔
812

13✔
813
  const da = await retrieveDA(service, fdaId, daId, servicePath);
13!
814
  if (!da?.query) {
13✔
815
    throw new FDAError(
13!
816
      404,
13✔
817
      'DaNotFound',
13✔
818
      `DA ${daId} does not exist in FDA ${fdaId} with service ${service}.`,
13✔
819
    );
1✔
820
  }
1✔
821

1✔
822
  const fda = await getAccessibleFDA(service, fdaId, visibility, servicePath);
13✔
823

12✔
824
  const validatedParams = resolveDAParams(rest || {}, da.params);
13✔
825
  const freshBaseQuery = buildFreshDAQuery(fda.query, da.query);
1✔
826

1✔
827
  return {
13✔
828
    ...replaceNamedParamsWithPositional(freshBaseQuery, validatedParams),
11✔
829
    fda,
11✔
830
  };
11✔
831
}
11✔
832

14✔
833
async function buildFreshFDAQuery(service, visibility, servicePath, fdaId) {
19✔
834
  const fda = await getAccessibleFDA(service, fdaId, visibility, servicePath);
21✔
835
  return { query: buildFreshQueryFromFDA(fda, fdaId), fda };
21✔
836
}
18✔
837

13✔
838
function buildFreshQueryFromFDA(fda, fdaId) {
35✔
839
  if (fda.cached !== false) {
23!
840
    throw new FDAError(
1✔
841
      409,
1✔
842
      'FDANotOnlyFresh',
1✔
843
      `FDA ${fdaId} is a cached FDA and cannot be queried directly. Use a Data Access instead.`,
13✔
844
    );
1✔
845
  }
1✔
846
  return removeTrailingSemicolon(fda.query?.trim() || '');
35!
847
}
31✔
848

12✔
849
function buildFreshDAQuery(fdaQuery, daQuery) {
13✔
850
  const cleanFdaQuery = removeTrailingSemicolon(fdaQuery?.trim() || '');
13✔
851
  const cleanDaQuery = removeTrailingSemicolon(daQuery?.trim() || '');
5✔
852

9✔
853
  if (!cleanDaQuery || /^from\b/i.test(cleanDaQuery)) {
9✔
854
    throw new FDAError(
9✔
855
      400,
9✔
856
      'InvalidDAQuery',
9✔
857
      'DA query must not include FROM clause at start. It is managed internally.',
10✔
858
    );
1✔
859
  }
1✔
860

1✔
861
  if (!/^select\b/i.test(cleanDaQuery)) {
1✔
862
    throw new FDAError(
1✔
863
      400,
1✔
864
      'InvalidDAQuery',
10✔
865
      'Fresh query mode requires DA query to start with SELECT.',
10✔
866
    );
8✔
867
  }
8✔
868

8✔
869
  const selectTail = cleanDaQuery.replace(/^select\s+/i, '');
10✔
870
  const clauseMatch = selectTail.match(
9✔
871
    /\b(where|group\s+by|having|order\s+by|limit|offset)\b/i,
5✔
872
  );
31✔
873

31✔
874
  const projection = clauseMatch
5✔
875
    ? selectTail.slice(0, clauseMatch.index).trim()
9✔
876
    : selectTail.trim();
9✔
877
  const clauses = clauseMatch ? selectTail.slice(clauseMatch.index).trim() : '';
9✔
878

9✔
879
  if (!projection) {
9✔
880
    throw new FDAError(
9!
881
      400,
×
882
      'InvalidDAQuery',
9✔
883
      'DA query must contain a SELECT projection.',
9✔
884
    );
9✔
885
  }
9✔
886

9✔
887
  if (/\bfrom\b/i.test(projection)) {
9✔
888
    throw new FDAError(
9!
889
      400,
×
890
      'InvalidDAQuery',
×
891
      'DA query must not include FROM clause. It is managed internally.',
9✔
892
    );
8✔
893
  }
8✔
894

8✔
895
  const trailing = clauses ? ` ${clauses}` : '';
9!
896
  return `SELECT ${projection} FROM (${cleanFdaQuery}) AS fda_source${trailing}`;
×
897
}
8✔
898

11✔
899
function replaceNamedParamsWithPositional(query, params) {
8✔
900
  const indexes = new Map();
8✔
901
  const values = [];
9!
902

9✔
903
  const text = query.replaceAll(/\$([A-Za-z_]\w*)/g, (_m, name) => {
9✔
904
    if (!Object.prototype.hasOwnProperty.call(params, name)) {
7✔
905
      /* c8 ignore next 5 */
2✔
906
      throw new FDAError(
2✔
907
        400,
2✔
908
        'InvalidQueryParam',
2✔
909
        `Missing required param "${name}".`,
2✔
910
      );
2✔
911
    }
5✔
912

41✔
913
    if (!indexes.has(name)) {
41✔
914
      indexes.set(name, values.length + 1);
41✔
915
      values.push(params[name]);
41✔
916
    }
41✔
917

41✔
918
    return `$${indexes.get(name)}`;
41✔
919
  });
41✔
920

41✔
921
  return { text, values };
41✔
922
}
41✔
923

44✔
924
function removeTrailingSemicolon(query) {
63✔
925
  return query.replace(/;+\s*$/, '');
63✔
926
}
63✔
927

44✔
928
export async function createDA(
44✔
929
  service,
227✔
930
  fdaId,
227✔
931
  daId,
227✔
932
  description,
227✔
933
  userQuery,
227✔
934
  params,
227✔
935
  visibility,
227✔
936
  servicePath,
227✔
937
) {
227✔
938
  const conn = await getDBConnection();
227✔
939

227✔
940
  try {
227✔
941
    let fda;
227✔
942
    if (visibility !== undefined || servicePath !== undefined) {
223!
943
      fda = await getAccessibleFDA(service, fdaId, visibility, servicePath);
227✔
944
      assertFDAIsCached(fda, fdaId);
190!
945
    }
190✔
946

188✔
947
    if (!fda) {
190!
948
      fda = await retrieveFDA(service, fdaId, servicePath);
×
949
    }
×
950

184✔
951
    const existing = await retrieveDA(service, fdaId, daId, servicePath);
225✔
952

210✔
953
    if (existing) {
212!
954
      throw new FDAError(
26✔
955
        409,
26✔
956
        'DuplicatedKey',
26✔
957
        `DA ${daId} already exists in FDA ${fdaId}`,
26✔
958
      );
26✔
959
    }
26✔
960

210✔
961
    const normalizedParams = checkParams(params);
210✔
962
    validateDAParamBindings(userQuery, normalizedParams || []);
212✔
963
    if (shouldValidateDACompatibility(fda)) {
212✔
964
      await validateDAQuery(conn, service, fdaId, userQuery, servicePath);
205✔
965
    }
185✔
966
    await storeDA(
203✔
967
      service,
186✔
968
      fdaId,
186✔
969
      servicePath,
186✔
970
      daId,
186✔
971
      description,
186✔
972
      userQuery,
186✔
973
      normalizedParams,
186✔
974
    );
186✔
975
  } finally {
210✔
976
    await releaseDBConnection(conn);
210✔
977
  }
210✔
978
}
210✔
979

27✔
980
export async function fetchFDA(
27✔
981
  fdaId,
210✔
982
  query,
210✔
983
  service,
210✔
984
  visibility,
210✔
985
  servicePath,
210✔
986
  description,
227✔
987
  refreshPolicy,
227✔
988
  timeColumn,
209✔
989
  objStgConf,
209✔
990
  defaultDataAccessEnabled,
209✔
991
  cached = true,
209✔
992
  datasourceId = DEFAULT_DATASOURCE_ID,
209✔
993
  validationMode = FDA_VALIDATION_MODE_STRICT,
209✔
994
) {
209✔
995
  const normalizedVisibility = normalizeVisibility(visibility);
227✔
996
  const normalizedServicePath = normalizeServicePath(servicePath);
212✔
997
  const datasource = await getDatasourceForService(service, datasourceId);
212✔
998
  validateScheduledOptions(refreshPolicy, objStgConf, timeColumn);
208✔
999

207✔
1000
  if (datasource.type === 'mongodb') {
211✔
1001
    validateMongoFDAContract(query, timeColumn, cached);
11✔
1002

30✔
1003
    if (refreshPolicy?.type === 'window') {
30!
1004
      throw new FDAError(
24✔
1005
        400,
24✔
1006
        'InvalidMongoFDAContract',
24✔
1007
        'Mongo datasource does not support window refresh policy',
24✔
1008
      );
24✔
1009
    }
24✔
1010
  }
30✔
1011

186✔
1012
  const sourceSchema = await validateAndGetSourceSchema(
186✔
1013
    datasource,
186✔
1014
    validationMode,
186✔
1015
    query,
186✔
1016
    timeColumn,
163✔
1017
  );
163✔
1018

153✔
1019
  const persistedSchema = buildPersistedSchema(sourceSchema);
153✔
1020

153✔
1021
  await createFDAMongo(
176✔
1022
    fdaId,
175✔
1023
    query,
175✔
1024
    service,
175✔
1025
    normalizedVisibility,
175✔
1026
    normalizedServicePath,
175✔
1027
    description,
175✔
1028
    refreshPolicy,
175✔
1029
    timeColumn,
175✔
1030
    objStgConf,
175✔
1031
    cached,
175✔
1032
    datasourceId,
175✔
1033
    validationMode,
175✔
1034
    persistedSchema,
157✔
1035
  );
175✔
1036

173✔
1037
  if (cached && validationMode === FDA_VALIDATION_MODE_STRICT) {
209✔
1038
    await prepareCachedFDA({
145✔
1039
      service,
145✔
1040
      fdaId,
145✔
1041
      query,
136✔
1042
      servicePath: normalizedServicePath,
136✔
1043
      datasourceId,
145✔
1044
      timeColumn,
131✔
1045
      objStgConf,
145!
1046
      defaultDataAccessEnabled,
122✔
1047
      visibility: normalizedVisibility,
122✔
1048
      sourceSchema,
122✔
1049
    });
122✔
1050
  }
122✔
1051

150✔
1052
  if (!cached) {
209✔
1053
    return;
31✔
1054
  }
31✔
1055

137✔
1056
  // Schedule refreshes according to the refresh policy
137✔
1057
  await scheduleFDAJobs({
137✔
1058
    fdaId,
137✔
1059
    query,
137✔
1060
    service,
137✔
1061
    servicePath: normalizedServicePath,
137✔
1062
    timeColumn,
133✔
1063
    refreshPolicy,
147✔
1064
    objStgConf,
147✔
1065
    datasource,
147✔
1066
  });
147✔
1067
}
205✔
1068

22✔
1069
function validateAndGetSourceSchema(
181✔
1070
  datasource,
179✔
1071
  validationMode,
179✔
1072
  query,
179✔
1073
  timeColumn,
179✔
1074
) {
179✔
1075
  if (
179✔
1076
    datasource.type !== 'postgres' ||
179✔
1077
    validationMode !== FDA_VALIDATION_MODE_STRICT
163✔
1078
  ) {
199✔
1079
    return null;
47✔
1080
  }
47✔
1081

189✔
1082
  return validatePostgresQuery(datasource.config, query, {
189!
1083
    timeColumn,
189✔
1084
    returnColumns: true,
189✔
1085
  });
189✔
1086
}
199✔
1087

40✔
1088
async function prepareCachedFDA({
159✔
1089
  service,
123✔
1090
  fdaId,
123✔
1091
  query,
123✔
1092
  servicePath,
123✔
1093
  datasourceId,
123✔
1094
  timeColumn,
123✔
1095
  objStgConf,
123✔
1096
  defaultDataAccessEnabled,
159✔
1097
  visibility,
158✔
1098
  sourceSchema,
159✔
1099
}) {
159✔
1100
  try {
142✔
1101
    await createParquet(
159✔
1102
      service,
159✔
1103
      fdaId,
129✔
1104
      query,
159✔
1105
      servicePath,
159✔
1106
      datasourceId,
123✔
1107
      timeColumn,
123✔
1108
      objStgConf,
123✔
1109
      sourceSchema,
123✔
1110
    );
123✔
1111
  } catch (err) {
123!
1112
    await rollbackFDAProvisioning(service, fdaId, servicePath);
37✔
1113
    throw err;
19✔
1114
  }
19✔
1115

141✔
1116
  await createDefaultDAIfNeeded({
159!
1117
    cached: true,
159✔
1118
    defaultDataAccessEnabled,
159✔
1119
    service,
159✔
1120
    fdaId,
159✔
1121
    normalizedServicePath: servicePath,
159!
1122
    timeColumn,
122✔
1123
    objStgConf,
122✔
1124
    normalizedVisibility: visibility,
122✔
1125
    sourceSchema,
159✔
1126
  });
141✔
1127
}
141✔
1128

22✔
1129
function resolveRefreshQueries(datasource, refreshPolicy, query, timeColumn) {
165!
1130
  if (refreshPolicy?.type !== 'window') {
128✔
1131
    return {
78✔
1132
      firstQuery: query,
115✔
1133
      recurringQuery: query,
97✔
1134
    };
115✔
1135
  }
90✔
1136

62✔
1137
  if (datasource.type === 'mongodb') {
165!
1138
    throw new FDAError(
×
1139
      400,
×
1140
      'InvalidMongoFDAContract',
×
1141
      'Mongo datasource does not support window refresh policy',
37✔
1142
    );
12✔
1143
  }
12✔
1144

62✔
1145
  return {
62✔
1146
    firstQuery: getWindowQuery(
62✔
1147
      query,
62✔
1148
      timeColumn,
62✔
1149
      refreshPolicy.params.windowSize,
62!
1150
    ),
50✔
1151
    recurringQuery: getWindowQuery(
50✔
1152
      query,
50✔
1153
      timeColumn,
62✔
1154
      refreshPolicy.params.fetchSize,
69✔
1155
    ),
87✔
1156
  };
62✔
1157
}
140✔
1158

15✔
1159
function getWindowQuery(query, timeColumn, startDate) {
139!
1160
  if (!startDate) {
102✔
1161
    return query;
16✔
1162
  }
16✔
1163
  const prevWindowStartDate = getPreviousWindowStartDate(startDate);
123✔
1164
  return getUpdateWindowQuery(query, timeColumn, prevWindowStartDate);
123✔
1165
}
113✔
1166

14✔
1167
function validateScheduledOptions(refreshPolicy, objStgConf, timeColumn) {
193✔
1168
  if (!refreshPolicy) {
183!
1169
    return;
1✔
1170
  }
1✔
1171

183✔
1172
  if (!VALID_REFRESH_POLICY_TYPES.includes(refreshPolicy.type)) {
219✔
1173
    throw new FDAError(
39✔
1174
      400,
13✔
1175
      'InvalidParam',
13✔
1176
      `Invalid refresh policy type "${refreshPolicy.type}".`,
39!
1177
    );
2✔
1178
  }
2✔
1179

180✔
1180
  if (refreshPolicy.type === 'none') {
182✔
1181
    return;
139✔
1182
  }
120✔
1183

96✔
1184
  if (
96✔
1185
    (refreshPolicy?.type === 'window' || objStgConf?.partition) &&
219✔
1186
    !timeColumn
71✔
1187
  ) {
183✔
1188
    throw new FDAError(
3✔
1189
      400,
3✔
1190
      'InvalidParam',
3✔
1191
      'timeColumn is required when using window refresh policy or partitioning.',
6✔
1192
    );
5✔
1193
  }
5✔
1194

79✔
1195
  const { refreshInterval, fetchSize } = refreshPolicy.params || {};
186!
1196
  const consistencyRefreshInterval =
182✔
1197
    refreshPolicy.params?.consistencyRefreshInterval;
186✔
1198
  if (!refreshInterval) {
185!
1199
    throw new FDAError(
3✔
1200
      400,
3✔
1201
      'InvalidParam',
3✔
1202
      `Missing required refresh policy parameter: refreshInterval.`,
3✔
1203
    );
3✔
1204
  }
3✔
1205

79✔
1206
  if (convertRefreshIntervalToMs(refreshInterval) === null) {
186✔
1207
    throw new FDAError(
3✔
1208
      400,
3✔
1209
      'InvalidParam',
3✔
1210
      `Invalid refresh interval "${refreshInterval}".`,
3✔
1211
    );
3✔
1212
  }
3✔
1213

75✔
1214
  if (refreshPolicy.type === 'window' && !fetchSize) {
186✔
1215
    throw new FDAError(
39✔
1216
      400,
18✔
1217
      'InvalidParam',
18✔
1218
      `Missing required refresh policy parameter: fetchSize.`,
18✔
1219
    );
18✔
1220
  }
18✔
1221

88✔
1222
  if (refreshPolicy.type === 'window' && fetchSize) {
219✔
1223
    const { unit } = processFetchSize(fetchSize);
65✔
1224
    if (!VALID_WINDOW_FETCH_SIZES.includes(unit)) {
65!
1225
      throw new FDAError(
37✔
1226
        400,
15✔
1227
        'InvalidParam',
15✔
1228
        `Invalid fetchSize "${fetchSize}".`,
15✔
1229
      );
15✔
1230
    }
37✔
1231
  }
73✔
1232

81✔
1233
  if (
109✔
1234
    refreshPolicy.type === 'window' &&
219✔
1235
    refreshPolicy.params?.windowSize &&
183✔
1236
    !objStgConf?.partition
47✔
1237
  ) {
183✔
1238
    throw new FDAError(
3✔
1239
      400,
3✔
1240
      'InvalidParam',
3✔
1241
      'windowSize requires objStgConf.partition.',
7✔
1242
    );
6✔
1243
  }
6✔
1244

74✔
1245
  if (refreshPolicy.params?.windowSize) {
186✔
1246
    const { unit } = processFetchSize(refreshPolicy.params.windowSize);
50✔
1247
    if (!getWindowDate(unit)) {
50!
1248
      throw new FDAError(
1✔
1249
        400,
1✔
1250
        'InvalidParam',
4✔
1251
        `Invalid windowSize "${refreshPolicy.params.windowSize}".`,
4✔
1252
      );
3✔
1253
    }
3✔
1254
  }
49✔
1255

71✔
1256
  if (
71✔
1257
    objStgConf?.partition &&
185✔
1258
    !PARTITION_TYPES.includes(objStgConf.partition)
61✔
1259
  ) {
185✔
1260
    throw new FDAError(
5✔
1261
      400,
5✔
1262
      'InvalidParam',
5✔
1263
      `Invalid partition type "${objStgConf.partition}".`,
5✔
1264
    );
5✔
1265
  }
5✔
1266

69✔
1267
  if (consistencyRefreshInterval) {
186✔
1268
    if (refreshPolicy.type !== 'window') {
6!
1269
      throw new FDAError(
4✔
1270
        400,
1✔
1271
        'InvalidParam',
1✔
1272
        'consistencyRefreshInterval is only supported for window refresh policy.',
4✔
1273
      );
3✔
1274
    }
3✔
1275

5✔
1276
    if (convertRefreshIntervalToMs(consistencyRefreshInterval) === null) {
5!
1277
      throw new FDAError(
3✔
1278
        400,
3✔
1279
        'InvalidParam',
3✔
1280
        `Invalid consistency refresh interval "${consistencyRefreshInterval}".`,
3✔
1281
      );
3✔
1282
    }
3✔
1283

5✔
1284
    const refreshMs = convertRefreshIntervalToMs(refreshInterval);
6✔
1285
    const consistencyMs = convertRefreshIntervalToMs(
6✔
1286
      consistencyRefreshInterval,
3✔
1287
    );
3✔
1288

3✔
1289
    if (
3✔
1290
      refreshMs !== null &&
3✔
1291
      consistencyMs !== null &&
3✔
1292
      consistencyMs < refreshMs
3✔
1293
    ) {
3!
1294
      throw new FDAError(
1✔
1295
        400,
1✔
1296
        'InvalidParam',
5✔
1297
        `consistencyRefreshInterval ("${consistencyRefreshInterval}") must be greater than refreshInterval ("${refreshInterval}").`,
6✔
1298
      );
6✔
1299
    }
6✔
1300
  }
8✔
1301

72✔
1302
  // RefreshInterval must be smaller or equal than partition size
72✔
1303
  if (!refreshIntervalPartitionCheck(refreshInterval, objStgConf?.partition)) {
188✔
1304
    throw new FDAError(
3✔
1305
      400,
3✔
1306
      'InvalidParam',
3✔
1307
      `Refresh interval "${refreshInterval}" must be smaller or equal than partition size "${objStgConf?.partition}".`,
8✔
1308
    );
7✔
1309
  }
7✔
1310

69✔
1311
  // fetched data size must be equal than partition size (if both presents).
69✔
1312
  if (objStgConf?.partition && fetchSize !== objStgConf?.partition) {
187✔
1313
    throw new FDAError(
7✔
1314
      400,
7✔
1315
      'InvalidParam',
7✔
1316
      `Fetch size "${fetchSize}" must be equal to partition size "${objStgConf?.partition}".`,
8✔
1317
    );
6✔
1318
  }
6✔
1319
}
186✔
1320

7✔
1321
export async function updateFDA(service, fdaId, visibility, servicePath) {
7✔
1322
  const normalizedServicePath = normalizeServicePath(servicePath);
16✔
1323
  const fda =
11✔
1324
    visibility !== undefined
11✔
1325
      ? await getAccessibleFDA(service, fdaId, visibility, servicePath)
11✔
1326
      : await getStoredFDA(service, fdaId, normalizedServicePath);
15!
1327

22!
1328
  assertFDAIsCached(fda, fdaId);
22✔
1329

22✔
1330
  const previous = await regenerateFDA(service, fdaId, normalizedServicePath);
22✔
1331

28✔
1332
  const agenda = getAgenda();
28!
1333

6✔
1334
  // Execute refresh immediately (when a fetcher is free)
6✔
1335
  const effectiveServicePath = previous.servicePath ?? normalizedServicePath;
10!
1336

10✔
1337
  let firstQuery = previous.query;
32✔
1338
  if (previous.refreshPolicy?.type === 'window') {
23✔
1339
    firstQuery = getWindowQuery(
15✔
1340
      previous.query,
15✔
1341
      previous.timeColumn,
15✔
1342
      previous.refreshPolicy?.params?.windowSize,
24✔
1343
    );
4✔
1344
  }
4✔
1345
  await agenda.now('refresh-fda', {
8✔
1346
    fdaId,
8✔
1347
    query: firstQuery,
8✔
1348
    service,
8✔
1349
    servicePath: effectiveServicePath,
8✔
1350
    timeColumn: previous.timeColumn,
28✔
1351
    refreshPolicy: previous.refreshPolicy,
12✔
1352
    objStgConf: previous.objStgConf,
12✔
1353
    datasourceId: previous.datasourceId ?? DEFAULT_DATASOURCE_ID,
16!
1354
  });
16✔
1355

12✔
1356
  if (previous.refreshPolicy?.params?.windowSize) {
32!
1357
    await agenda.now('clean-partition', {
2✔
1358
      fdaId,
2✔
1359
      service,
2✔
1360
      servicePath: effectiveServicePath,
2✔
1361
      windowSize: previous.refreshPolicy.params.windowSize,
24✔
1362
      objStgConf: previous.objStgConf,
3✔
1363
    });
3✔
1364
  }
3✔
1365
}
11✔
1366

4✔
1367
export async function processFDAAsync(
25✔
1368
  fdaId,
147✔
1369
  query,
159✔
1370
  service,
159✔
1371
  servicePath,
159✔
1372
  timeColumn,
159✔
1373
  refreshPolicy,
159✔
1374
  objStgConf,
159✔
1375
  datasourceId = DEFAULT_DATASOURCE_ID,
159✔
1376
) {
159✔
1377
  const datasource = await resolveDatasource(service, datasourceId);
147✔
1378

168✔
1379
  if (datasource.type === 'mongodb' && refreshPolicy?.type === 'window') {
168!
1380
    throw new FDAError(
26!
1381
      400,
26!
UNCOV
1382
      'InvalidMongoFDAContract',
×
UNCOV
1383
      'Mongo datasource does not support window refresh',
×
UNCOV
1384
    );
×
UNCOV
1385
  }
×
1386

142✔
1387
  const storagePath = getFDAStoragePath(fdaId, servicePath);
168✔
1388
  const bucketName = getBucketNameFromService(service);
168✔
1389

168✔
1390
  try {
142!
1391
    await updateFDAStatus({
142✔
1392
      service,
142✔
1393
      fdaId,
142✔
1394
      servicePath,
142✔
1395
      status: 'fetching',
147✔
1396
      progress: 10,
154✔
1397
    });
154✔
1398

154✔
1399
    await uploadTableToObjStg(
154✔
1400
      service,
154✔
1401
      datasourceId,
152✔
1402
      query,
154✔
1403
      bucketName,
154✔
1404
      storagePath,
147✔
1405
      fdaId,
143✔
1406
      servicePath,
143✔
1407
      timeColumn,
143✔
1408
      objStgConf,
143✔
1409
    );
143✔
1410

141✔
1411
    await updateFDAStatus({
141✔
1412
      service,
141✔
1413
      fdaId,
141✔
1414
      servicePath,
141✔
1415
      status: 'completed',
141✔
1416
      progress: 100,
141✔
1417
    });
141✔
1418
  } catch (err) {
143✔
1419
    await updateFDAStatus({
3✔
1420
      service,
3✔
1421
      fdaId,
3✔
1422
      servicePath,
3!
1423
      status: 'failed',
3✔
1424
      progress: 0,
3✔
1425
      error: err.message,
3✔
1426
    });
3✔
1427
    throw err;
3✔
1428
  }
3✔
1429
}
143✔
1430

4✔
1431
function getPreviousWindowStartDate(fetchSize) {
93✔
1432
  const now = new Date();
93✔
1433
  const { amount, unit } = processFetchSize(fetchSize);
93✔
1434

93✔
1435
  switch (unit) {
93✔
1436
    case 'hour': {
93✔
1437
      const d = new Date(now);
3✔
1438
      d.setUTCHours(d.getUTCHours() - amount, 0, 0, 0);
4✔
1439
      return d.toISOString();
4✔
1440
    }
4✔
1441

94✔
1442
    case 'day': {
94✔
1443
      const d = new Date(now);
41✔
1444
      d.setUTCDate(d.getUTCDate() - amount);
41✔
1445
      d.setUTCHours(0, 0, 0, 0);
41!
1446
      return d.toISOString();
41!
1447
    }
40✔
1448

92✔
1449
    case 'week': {
92✔
1450
      const d = new Date(now);
27✔
1451
      d.setUTCDate(d.getUTCDate() - 7 * amount);
27!
1452
      d.setUTCHours(0, 0, 0, 0);
26✔
1453
      return d.toISOString();
26✔
1454
    }
26✔
1455

92✔
1456
    case 'month': {
92✔
1457
      const d = new Date(now);
14✔
1458
      d.setUTCMonth(d.getUTCMonth() - amount);
14✔
1459
      d.setUTCHours(0, 0, 0, 0);
15✔
1460
      return d.toISOString();
19✔
1461
    }
15✔
1462

93✔
1463
    case 'year': {
93✔
1464
      const d = new Date(now);
11✔
1465
      d.setUTCFullYear(d.getUTCFullYear() - amount);
11✔
1466
      d.setUTCHours(0, 0, 0, 0);
11✔
1467
      return d.toISOString();
11✔
1468
    }
11✔
1469

93✔
1470
    default:
93!
1471
      throw new FDAError(
1✔
1472
        400,
1!
UNCOV
1473
        'InvalidParam',
×
UNCOV
1474
        `Invalid unit in window param: ${fetchSize}.`,
×
UNCOV
1475
      );
×
1476
  }
93✔
1477
}
93✔
1478

4✔
1479
function getUpdateWindowQuery(query, timeColumn, latestFetchStartDate) {
87✔
1480
  return `SELECT * FROM (${query}) q WHERE ${timeColumn} >= TIMESTAMP '${latestFetchStartDate}' AND ${timeColumn} < NOW()`;
91✔
1481
}
93✔
1482

10✔
1483
function buildPersistedSchema(sourceSchema) {
159✔
1484
  const schemaFields = Array.isArray(sourceSchema?.fields)
159✔
1485
    ? sourceSchema.fields.filter(
159!
1486
        (field) =>
149✔
1487
          typeof field?.name === 'string' &&
561✔
1488
          field.name.length > 0 &&
561✔
1489
          typeof field?.duckdbType === 'string' &&
560✔
1490
          field.duckdbType.length > 0,
148✔
1491
      )
148✔
1492
    : [];
158✔
1493

158✔
1494
  if (schemaFields.length === 0) {
159!
1495
    return null;
17✔
1496
  }
11✔
1497

143✔
1498
  return schemaFields.map(({ name, duckdbType }) => ({
143✔
1499
    name,
555✔
1500
    type: duckdbType,
561✔
1501
  }));
147✔
1502
}
157✔
1503

8✔
1504
function shouldValidateDACompatibility(fda) {
173✔
1505
  return (
173✔
1506
    (fda?.validationMode ?? FDA_VALIDATION_MODE_STRICT) !==
173!
1507
    FDA_VALIDATION_MODE_UNCHECKED
173✔
1508
  );
173✔
1509
}
173✔
1510

8✔
1511
async function uploadMongoCursorContentToObjectStorage(
13✔
1512
  s3Client,
13✔
1513
  bucket,
13✔
1514
  path,
13✔
1515
  reader,
13✔
1516
) {
13✔
1517
  const uploadBody = new PassThrough();
13✔
1518
  const upload = newUpload(s3Client, bucket, `${path}.csv`, uploadBody, 5, 1);
13✔
1519

13✔
1520
  const uploadDone = upload.done();
13✔
1521

13✔
1522
  try {
13✔
1523
    const columns = reader.columns || [];
13!
1524
    let wroteHeader = false;
13✔
1525

13✔
1526
    let rows = await reader.readNextChunk();
13✔
1527
    while (rows.length > 0) {
13✔
1528
      if (!wroteHeader) {
9✔
1529
        if (columns.length > 0) {
9✔
1530
          await writeCsvHeader(uploadBody, columns);
9✔
1531
        }
10✔
1532
        wroteHeader = true;
9✔
1533
      }
9✔
1534

9✔
1535
      for (const row of rows) {
9✔
1536
        const csvLine = columns
11✔
1537
          .map((column) => escapeCsvValue(row[column]))
11✔
1538
          .join(',');
11✔
1539
        await writeCsvLine(uploadBody, `${csvLine}\n`);
11✔
1540
      }
11✔
1541

9✔
1542
      rows = await reader.readNextChunk();
9✔
1543
    }
9✔
1544

13✔
1545
    if (!wroteHeader && columns.length > 0) {
13✔
1546
      await writeCsvHeader(uploadBody, columns);
9✔
1547
    }
9✔
1548

13✔
1549
    uploadBody.end();
13✔
1550
    await uploadDone;
13✔
1551
  } catch (error) {
13!
1552
    uploadBody.destroy(error);
5✔
1553
    await uploadDone.catch(() => {});
5✔
UNCOV
1554
    throw new FDAError(
×
UNCOV
1555
      503,
×
UNCOV
1556
      'UploadError',
×
1557
      `Error uploading FDA to object storage: ${error.message}`,
×
1558
    );
×
1559
  } finally {
8✔
1560
    await reader.close();
8✔
1561
  }
8✔
1562
}
8✔
1563

3✔
1564
async function createMongoFDAReader(
8✔
1565
  service,
8✔
1566
  datasourceId,
8✔
1567
  fdaId,
13✔
1568
  servicePath,
13✔
1569
  { limit } = {},
10✔
1570
) {
10✔
1571
  const datasource = await resolveDatasource(service, datasourceId);
10✔
1572
  const fda = await retrieveFDA(service, fdaId, servicePath);
10!
1573

8✔
1574
  if (!fda) {
8!
UNCOV
1575
    throw new FDAError(
×
1576
      404,
2✔
1577
      'FDANotFound',
2✔
1578
      `FDA ${fdaId} not found in service ${service}`,
2✔
1579
    );
1✔
1580
  }
1✔
1581

9✔
1582
  validateMongoFDAContract(fda.query, fda.timeColumn, fda.cached);
9✔
1583

9✔
1584
  return await createMongoCursorReader(datasource.config, fda.query, {
13✔
1585
    limit,
13✔
1586
  });
12✔
1587
}
12✔
1588

7✔
1589
export async function deleteFDA(service, fdaId, visibility, servicePath) {
7✔
1590
  let targetServicePath = normalizeServicePath(servicePath);
78✔
1591

78✔
1592
  if (visibility !== undefined || servicePath !== undefined) {
78!
1593
    const fda = await getAccessibleFDA(service, fdaId, visibility, servicePath);
78✔
1594
    targetServicePath = fda.servicePath;
60✔
1595
  }
59✔
1596

59✔
1597
  const { _id } = (await retrieveFDA(service, fdaId, targetServicePath)) ?? {};
78!
1598

78✔
1599
  if (!service || !_id) {
78!
1600
    throw new FDAError(
4✔
1601
      404,
4✔
1602
      'FDANotFound',
4!
1603
      `FDA ${fdaId} of the service ${service} not found.`,
4✔
1604
    );
3✔
1605
  }
3✔
1606
  const bucketName = getBucketNameFromService(service);
59✔
1607
  const s3Client = await getS3Client(
59✔
1608
    `${config.objstg.protocol}://${config.objstg.endpoint}`,
59✔
1609
    config.objstg.usr,
60✔
1610
    config.objstg.pass,
60✔
1611
  );
60✔
1612
  // This way we remove FDAs independently of if theyre partitioned or not
61✔
1613
  const storagePath = getFDAStoragePath(fdaId, targetServicePath);
58✔
1614
  const allObjPaths = await listObjects(s3Client, bucketName, storagePath);
58✔
1615
  // Filter strictly to objects belonging to this FDA only, preventing
61✔
1616
  // accidental deletion of sibling FDAs whose IDs share a common prefix
66✔
1617
  // (e.g. fda_test and fda_test_1 both match the prefix "fda_test").
66✔
1618
  const objPaths = allObjPaths.filter(
61✔
1619
    (key) =>
61✔
1620
      key.startsWith(`${storagePath}/`) || key.startsWith(`${storagePath}.`),
61✔
1621
  );
61✔
1622
  await dropFiles(s3Client, bucketName, objPaths);
61✔
1623

61✔
1624
  await removeFDA(service, fdaId, targetServicePath);
61✔
1625

61✔
1626
  const agenda = getAgenda();
66!
1627
  await agenda.cancel(
56✔
1628
    buildFDAJobCancelFilter(
56✔
1629
      'refresh-fda-recurring',
56✔
1630
      service,
56✔
1631
      fdaId,
66✔
1632
      targetServicePath,
60✔
1633
    ),
60✔
1634
  );
60✔
1635
  await agenda.cancel(
60✔
1636
    buildFDAJobCancelFilter(
60✔
1637
      'consistency-refresh-fda-recurring',
60✔
1638
      service,
60✔
1639
      fdaId,
60✔
1640
      targetServicePath,
60✔
1641
    ),
60✔
1642
  );
60✔
1643
  await agenda.cancel(
60✔
1644
    buildFDAJobCancelFilter(
60✔
1645
      'clean-partition-recurring',
60✔
1646
      service,
60✔
1647
      fdaId,
60✔
1648
      targetServicePath,
60✔
1649
    ),
60✔
1650
  );
60✔
1651
}
78✔
1652

7✔
1653
export async function getDAs(service, fdaId, visibility, servicePath) {
7✔
1654
  if (visibility !== undefined || servicePath !== undefined) {
6!
1655
    await getAccessibleFDA(service, fdaId, visibility, servicePath);
6✔
1656
  }
10✔
1657

10✔
1658
  return retrieveDAs(service, fdaId, servicePath);
6✔
1659
}
6✔
1660

7✔
1661
export async function getDA(service, fdaId, daId, visibility, servicePath) {
13✔
1662
  if (visibility !== undefined || servicePath !== undefined) {
12!
1663
    await getAccessibleFDA(service, fdaId, visibility, servicePath);
14✔
1664
  }
8✔
1665

8✔
1666
  const da = await retrieveDA(service, fdaId, daId, servicePath);
9✔
1667
  if (da) {
9✔
1668
    da.id = daId;
7✔
1669
  } else {
7✔
1670
    throw new FDAError(
7✔
1671
      404,
7✔
1672
      'DaNotFound',
7✔
1673
      `DA ${daId} not found in FDA ${fdaId} and service ${service}.`,
7✔
1674
    );
7✔
1675
  }
7✔
1676

7✔
1677
  return da;
7✔
1678
}
9✔
1679

8✔
1680
export async function putDA(
8✔
1681
  service,
7✔
1682
  fdaId,
7✔
1683
  daId,
7✔
1684
  description,
7✔
1685
  userQuery,
7✔
1686
  params,
7✔
1687
  visibility,
7✔
1688
  servicePath,
7✔
1689
) {
7✔
1690
  const conn = await getDBConnection();
7✔
1691

7✔
1692
  try {
7✔
1693
    let fda;
7✔
1694

7✔
1695
    if (visibility !== undefined || servicePath !== undefined) {
7!
1696
      fda = await getAccessibleFDA(service, fdaId, visibility, servicePath);
7✔
1697
    }
7✔
1698

7✔
1699
    if (!fda) {
7!
1700
      fda = await retrieveFDA(service, fdaId, servicePath);
4✔
1701
    }
4✔
1702

6✔
1703
    const normalizedParams = checkParams(params);
7✔
1704
    validateDAParamBindings(userQuery, normalizedParams || []);
3!
1705
    if (shouldValidateDACompatibility(fda)) {
3✔
1706
      await validateDAQuery(conn, service, fdaId, userQuery, servicePath);
7✔
1707
    }
6✔
1708
    await updateDA(
6✔
1709
      service,
6✔
1710
      fdaId,
6✔
1711
      servicePath,
6✔
1712
      daId,
6✔
1713
      description,
6✔
1714
      userQuery,
6✔
1715
      normalizedParams,
6✔
1716
    );
6✔
1717
  } finally {
6✔
1718
    await releaseDBConnection(conn);
6✔
1719
  }
6✔
1720
}
6✔
1721

7✔
1722
export async function deleteDA(service, fdaId, daId, visibility, servicePath) {
7✔
1723
  if (visibility !== undefined || servicePath !== undefined) {
4✔
1724
    await getAccessibleFDA(service, fdaId, visibility, servicePath);
5!
1725
  }
4✔
1726

4✔
1727
  await removeDA(service, fdaId, daId, servicePath);
5✔
1728
}
5✔
1729

8✔
1730
export async function cleanPartition(
8✔
1731
  service,
11✔
1732
  fdaId,
11✔
1733
  windowSize,
11✔
1734
  objStgConf,
9✔
1735
  servicePath,
11!
1736
) {
6✔
1737
  if (!objStgConf?.partition) {
6!
UNCOV
1738
    // DEBATE: With no partitioned folders doesn't make much sense to clean cause we'd had a FDA with no file
×
1739
    throw new FDAError(
5✔
1740
      400,
4✔
1741
      'CleaningError',
4✔
1742
      `Removing a non partitioned FDA ${fdaId}.`,
4!
1743
    );
4!
1744
  }
×
1745

6✔
1746
  const cutoff = new Date(getPreviousWindowStartDate(windowSize));
11!
1747
  if (!cutoff) {
6!
1748
    /* c8 ignore next 5 */
2✔
1749
    throw new FDAError(
2✔
1750
      400,
2✔
1751
      'CleaningError',
2✔
1752
      'Incorrect window size in refresh policy.',
2✔
1753
    );
2✔
1754
  }
×
1755

6✔
1756
  /* c8 ignore next 6 */
2✔
1757
  const s3Client = getS3Client(
2✔
1758
    `${config.objstg.protocol}://${config.objstg.endpoint}`,
2✔
1759
    config.objstg.usr,
2✔
1760
    config.objstg.pass,
2✔
1761
  );
2✔
1762
  const bucketName = getBucketNameFromService(service);
2✔
1763

6✔
1764
  /* c8 ignore next 10 */
2✔
1765
  const cleanPartitionStoragePath = getFDAStoragePath(fdaId, servicePath);
2✔
1766
  const allPartitionPaths = await listObjects(
2✔
1767
    s3Client,
2✔
1768
    bucketName,
2✔
1769
    cleanPartitionStoragePath,
2✔
1770
  );
2✔
1771
  // Filter strictly to objects belonging to this FDA only (same prefix-collision guard as deleteFDA)
2✔
1772
  const objPaths = allPartitionPaths.filter((key) =>
2✔
1773
    key.startsWith(`${cleanPartitionStoragePath}/`),
2✔
1774
  );
2✔
1775

6✔
1776
  const partitionsToRemove = [];
6✔
1777
  for (const path of objPaths) {
11✔
1778
    const partitionDate = extractDate(path);
7!
1779

2✔
1780
    if (partitionDate < cutoff) {
2!
1781
      partitionsToRemove.push(path);
5✔
1782
    }
4✔
1783
  }
6✔
1784
  await dropFiles(s3Client, bucketName, partitionsToRemove);
11✔
1785
}
12✔
1786

9✔
1787
async function uploadTableToObjStg(
148✔
1788
  service,
148✔
1789
  datasourceId,
148✔
1790
  query,
148✔
1791
  bucket,
148✔
1792
  path,
148✔
1793
  fdaId,
148✔
1794
  servicePath,
148✔
1795
  timeColumn,
148✔
1796
  objStgConf,
148✔
1797
) {
148✔
1798
  const s3Client = getS3Client(
148✔
1799
    `${config.objstg.protocol}://${config.objstg.endpoint}`,
148✔
1800
    config.objstg.usr,
148!
1801
    config.objstg.pass,
142✔
1802
  );
142✔
1803
  const datasource = await resolveDatasource(service, datasourceId);
142✔
1804
  const fda = await retrieveFDA(service, fdaId, servicePath);
147✔
1805
  const schemaFields = normalizePersistedSchemaFields(fda?.schema);
153✔
1806
  await updateFDAStatus({ service, fdaId, servicePath, progress: 20 });
153✔
1807

147✔
1808
  if (datasource.type === 'postgres') {
147✔
1809
    await uploadTable(s3Client, bucket, datasource.config, query, path);
143✔
1810
  } else {
189✔
1811
    const reader = await createMongoFDAReader(
51✔
1812
      service,
51✔
1813
      datasourceId,
51✔
1814
      fdaId,
51✔
1815
      servicePath,
51✔
1816
    );
51✔
1817
    await uploadMongoCursorContentToObjectStorage(
51✔
1818
      s3Client,
51✔
1819
      bucket,
51✔
1820
      path,
51✔
1821
      reader,
51✔
1822
    );
51✔
1823
  }
51✔
1824

189✔
1825
  const conn = await getDBConnection();
189✔
1826
  try {
144✔
1827
    await updateFDAStatus({
144✔
1828
      service,
142✔
1829
      fdaId,
142✔
1830
      servicePath,
144✔
1831
      status: 'transforming',
144✔
1832
      progress: 60,
144✔
1833
    });
144✔
1834

144✔
1835
    // DuckDB cant overwrite files in Minio, so for partitioned files we upload them in a tmp file and the move them
144!
1836
    // This includes first upload because the one row parquet is also partitioned
142✔
1837
    const parquetPath = objStgConf?.partition
142✔
1838
      ? getPath(bucket, 'tmp/' + path, '')
142✔
1839
      : getPath(bucket, path, '.parquet');
142✔
1840

142✔
1841
    const csvPath = getPath(bucket, path, '.csv');
142✔
1842
    if (datasource.type === 'postgres' && schemaFields.length > 0) {
142✔
1843
      await copyQueryToParquet(
132✔
1844
        conn,
179✔
1845
        buildTypedCsvSourceQuery(csvPath, schemaFields),
177✔
1846
        parquetPath,
179✔
1847
        timeColumn,
133✔
1848
        objStgConf?.partition,
133✔
1849
        objStgConf?.compression,
133✔
1850
      );
133✔
1851
    } else {
189✔
1852
      await toParquet(
54✔
1853
        conn,
54✔
1854
        csvPath,
54✔
1855
        parquetPath,
57!
1856
        timeColumn,
10✔
1857
        objStgConf?.partition,
10✔
1858
        objStgConf?.compression,
10✔
1859
      );
10✔
1860
    }
8✔
1861

140✔
1862
    if (objStgConf?.partition) {
189✔
1863
      const objectsList = await listObjects(s3Client, bucket, `tmp/${path}/`);
59✔
1864
      const hasRealPartitionedParquet = objectsList.some((key) =>
190!
1865
        key.endsWith('.parquet'),
54✔
1866
      );
54✔
1867
      for (const tempPartition of objectsList) {
59✔
1868
        await moveObject(
38✔
1869
          s3Client,
38!
1870
          bucket,
38✔
1871
          `${bucket}/${tempPartition}`,
38✔
1872
          tempPartition.replace('tmp/', ''),
38✔
1873
        );
38✔
1874
        await dropFile(s3Client, bucket, tempPartition);
38✔
1875
      }
38✔
1876

58✔
1877
      if (hasRealPartitionedParquet) {
58✔
1878
        await dropFile(
30✔
1879
          s3Client,
28✔
1880
          bucket,
28✔
1881
          `${path}/${getSchemaPartitionPath(objStgConf.partition)}/schema.parquet`,
31✔
1882
        );
50✔
1883
      }
50✔
1884
    }
78✔
1885

164✔
1886
    await updateFDAStatus({
164✔
1887
      service,
164✔
1888
      fdaId,
164✔
1889
      servicePath,
164✔
1890
      status: 'uploading',
164✔
1891
      progress: 80,
164✔
1892
    });
164✔
1893
    // DuckDb doesn't replace one row parquet snippet with partitioned file, so we remove it by hand
164✔
1894
    if (objStgConf?.partition) {
166✔
1895
      await dropFile(s3Client, bucket, `${path}.parquet`);
78✔
1896
    }
78✔
1897
    await dropFile(s3Client, bucket, `${path}.csv`);
164✔
1898
  } catch (e) {
166✔
1899
    throw new FDAError(500, 'UploadError', e.message);
26✔
1900
  } finally {
166✔
1901
    await releaseDBConnection(conn);
166✔
1902
  }
166✔
1903
}
166✔
1904

27✔
1905
async function ensureFDAReadyForQuery(service, fdaId, visibility, servicePath) {
164!
1906
  const fda = await getAccessibleFDA(service, fdaId, visibility, servicePath);
140✔
1907
  assertFDAIsCached(fda, fdaId);
134✔
1908

134✔
1909
  // Queries are blocked only before the first successful fetch.
134✔
1910
  if (!fda.lastFetch) {
140✔
1911
    throw new FDAError(
2✔
1912
      409,
2✔
1913
      'FDAUnavailable',
26✔
1914
      `FDA ${fdaId} is not queryable yet because the first fetch has not completed`,
26✔
1915
    );
26✔
1916
  }
26✔
1917
}
164✔
1918

27✔
1919
export async function getStoredFDA(service, fdaId, servicePath) {
27!
UNCOV
1920
  const fda = await retrieveFDA(service, fdaId, servicePath);
×
1921

×
1922
  if (!fda) {
×
1923
    throw new FDAError(
×
1924
      404,
×
1925
      'FDANotFound',
×
1926
      `FDA ${fdaId} not found in service ${service}`,
24✔
1927
    );
23✔
1928
  }
23✔
1929

23✔
1930
  return fda;
23✔
1931
}
23✔
1932

26✔
1933
async function getAccessibleFDA(service, fdaId, visibility, servicePath) {
2,759✔
1934
  const normalizedVisibility = normalizeVisibility(visibility);
2,760✔
1935
  const normalizedServicePath = normalizeServicePath(servicePath);
2,760✔
1936
  const fda = await retrieveFDA(service, fdaId, normalizedServicePath);
2,760✔
1937

2,739✔
1938
  if (!fda) {
2,760✔
1939
    const sameIdCandidates = (await retrieveFDAs(service)).filter(
46✔
1940
      (candidate) => candidate.fdaId === fdaId,
46✔
1941
    );
46✔
1942

46✔
1943
    if (sameIdCandidates.length === 0) {
46✔
1944
      throw new FDAError(
44✔
1945
        404,
44✔
1946
        'FDANotFound',
44✔
1947
        `FDA ${fdaId} not found in service ${service}`,
25✔
1948
      );
20✔
1949
    }
20✔
1950

2✔
1951
    const hasMatchingVisibility = sameIdCandidates.some(
7✔
1952
      (candidate) =>
26✔
1953
        normalizeVisibility(candidate.visibility) === normalizedVisibility,
26!
1954
    );
2✔
1955

2✔
1956
    if (!hasMatchingVisibility) {
22!
UNCOV
1957
      throw new FDAError(
×
UNCOV
1958
        403,
×
UNCOV
1959
        'VisibilityMismatch',
×
1960
        `FDA ${fdaId} does not belong to ${normalizedVisibility}`,
24✔
1961
      );
24✔
1962
    }
24✔
1963

26✔
1964
    throw new FDAError(
26!
1965
      403,
2✔
1966
      'ServicePathMismatch',
2✔
1967
      `FDA ${fdaId} does not belong to servicePath ${normalizedServicePath}`,
2✔
1968
    );
2✔
1969
  }
2✔
1970

2,712✔
1971
  if (normalizeVisibility(fda.visibility) !== normalizedVisibility) {
2,736✔
1972
    throw new FDAError(
4✔
1973
      403,
4✔
1974
      'VisibilityMismatch',
4✔
1975
      `FDA ${fdaId} does not belong to ${normalizedVisibility}`,
4✔
1976
    );
4✔
1977
  }
4✔
1978

2,708✔
1979
  if (normalizeServicePath(fda.servicePath) !== normalizedServicePath) {
2,741!
1980
    throw new FDAError(
5✔
1981
      403,
5✔
1982
      'ServicePathMismatch',
5!
UNCOV
1983
      `FDA ${fdaId} does not belong to servicePath ${normalizedServicePath}`,
×
UNCOV
1984
    );
×
UNCOV
1985
  }
×
1986

2,708✔
1987
  return fda;
2,708✔
1988
}
2,736✔
1989

3✔
1990
function normalizeVisibility(visibility) {
5,742✔
1991
  if (!VALID_VISIBILITIES_SET.has(visibility)) {
5,747✔
1992
    throw new FDAError(
2✔
1993
      400,
2✔
1994
      'InvalidVisibility',
2✔
1995
      'Visibility must be public or private',
7✔
1996
    );
2✔
1997
  }
2✔
1998

5,740✔
1999
  return visibility;
5,740✔
2000
}
5,742✔
2001

3✔
2002
function toFDAApiResponse(fda, { includeId }) {
2,390✔
2003
  if (!fda) {
2,390!
2004
    return fda;
×
2005
  }
×
2006

2,390✔
2007
  const response = { ...fda };
2,390✔
2008
  const fdaId = response.fdaId;
2,390✔
2009

2,390✔
2010
  delete response._id;
2,390✔
2011
  delete response.fdaId;
2,390✔
2012
  delete response.service;
2,390✔
2013
  delete response.visibility;
2,395✔
2014
  delete response.servicePath;
2,397✔
2015

2,397✔
2016
  if (!includeId) {
2,397✔
2017
    return response;
2,299✔
2018
  }
2,299✔
2019

105✔
2020
  return {
105✔
2021
    id: fdaId,
105✔
2022
    ...response,
105✔
2023
  };
105✔
2024
}
2,397✔
2025

10✔
2026
async function createParquet(
129✔
2027
  service,
129!
2028
  fdaId,
129!
2029
  query,
122✔
2030
  servicePath,
122✔
2031
  datasourceId,
122✔
2032
  timeColumn,
129✔
2033
  objStgConf,
129✔
2034
  sourceSchema,
129✔
2035
) {
122!
2036
  const s3Client = getS3Client(
122✔
2037
    `${config.objstg.protocol}://${config.objstg.endpoint}`,
122✔
2038
    config.objstg.usr,
122✔
2039
    config.objstg.pass,
122✔
2040
  );
122✔
2041
  const storagePath = getFDAStoragePath(fdaId, servicePath);
129✔
2042
  const bucketName = getBucketNameFromService(service);
129✔
2043
  const datasource = await resolveDatasource(service, datasourceId);
129!
2044
  const parquetPath = getPath(bucketName, storagePath, '.parquet');
129✔
2045

129✔
2046
  if (datasource.type === 'postgres') {
129✔
2047
    const typedEmptyQuery = buildTypedEmptyQueryFromSchema(sourceSchema);
125✔
2048

125✔
2049
    if (typedEmptyQuery) {
125✔
2050
      const conn = await getDBConnection();
125✔
2051
      try {
125✔
2052
        await copyQueryToParquet(
120✔
2053
          conn,
125✔
2054
          typedEmptyQuery,
125✔
2055
          parquetPath,
119✔
2056
          timeColumn,
119✔
2057
          objStgConf?.partition,
120✔
2058
          objStgConf?.compression,
120✔
2059
        );
119✔
2060
      } finally {
119✔
2061
        await releaseDBConnection(conn);
119✔
2062
      }
119✔
2063

119✔
2064
      return;
119✔
2065
    }
119✔
2066

1!
2067
    const oneRowQuery = buildZeroRowQuery(query);
1✔
2068
    await uploadTable(
2!
UNCOV
2069
      s3Client,
×
UNCOV
2070
      bucketName,
×
UNCOV
2071
      datasource.config,
×
2072
      oneRowQuery,
1✔
2073
      storagePath,
1✔
2074
    );
1✔
2075
  } else {
123✔
2076
    const reader = await createMongoFDAReader(
5✔
2077
      service,
5✔
2078
      datasourceId,
5✔
2079
      fdaId,
5✔
2080
      servicePath,
11✔
2081
      { limit: 1 },
10✔
2082
    );
10✔
2083
    await uploadMongoCursorContentToObjectStorage(
10✔
2084
      s3Client,
10✔
2085
      bucketName,
10✔
2086
      storagePath,
11✔
2087
      reader,
17✔
2088
    );
17✔
2089
  }
17✔
2090

17✔
2091
  const conn = await getDBConnection();
17✔
2092
  try {
17✔
2093
    await toParquet(
17✔
2094
      conn,
17✔
2095
      getPath(bucketName, storagePath, '.csv'),
17✔
2096
      parquetPath,
17✔
2097
      timeColumn,
17✔
2098
      objStgConf?.partition,
135✔
2099
      objStgConf?.compression,
135✔
2100
    );
135✔
2101

9✔
2102
    await dropFile(s3Client, bucketName, `${storagePath}.csv`);
17✔
2103
  } finally {
17✔
2104
    await releaseDBConnection(conn);
17✔
2105
  }
17✔
2106
}
135✔
2107

16✔
2108
function buildZeroRowQuery(query) {
13✔
2109
  const normalizedQuery = query.trim().replace(/;+\s*$/, '');
2✔
2110

2✔
2111
  // Schema-only bootstrap keeps creation validation synchronous without row materialization.
2✔
2112
  return `SELECT * FROM (${normalizedQuery}) AS fda_one_row LIMIT 0`;
13✔
2113
}
11✔
2114

14✔
2115
function isValidDuckDBType(type) {
965✔
2116
  return /^[A-Za-z0-9_,()\s]+$/.test(type);
965✔
2117
}
960✔
2118

5✔
2119
function buildTypedEmptyQueryFromSchema(sourceSchema) {
120✔
2120
  const fields = Array.isArray(sourceSchema?.fields)
120✔
2121
    ? sourceSchema.fields.filter(
120✔
2122
        (field) =>
120✔
2123
          typeof field?.name === 'string' &&
458✔
2124
          field.name.length > 0 &&
458✔
2125
          typeof field?.duckdbType === 'string' &&
458✔
2126
          field.duckdbType.length > 0,
120✔
2127
      )
120✔
2128
    : [];
120!
2129

120✔
2130
  if (fields.length === 0) {
120!
2131
    return null;
2✔
2132
  }
2✔
2133

120✔
2134
  const projections = fields.map(({ name, duckdbType }) => {
125✔
2135
    if (!isValidDuckDBType(duckdbType)) {
462!
2136
      throw new FDAError(
6✔
2137
        500,
6✔
2138
        'InvalidSchemaType',
6✔
2139
        `Invalid schema type for column ${name}: ${duckdbType}`,
7!
2140
      );
7✔
2141
    }
7✔
2142

463✔
2143
    return `CAST(NULL AS ${duckdbType}) AS ${quoteDuckDBIdentifier(name)}`;
463✔
2144
  });
125✔
2145

125✔
2146
  return `SELECT ${projections.join(', ')} WHERE FALSE`;
123✔
2147
}
125✔
2148

10✔
2149
function normalizePersistedSchemaFields(schema) {
149✔
2150
  if (!Array.isArray(schema)) {
149✔
2151
    return [];
17✔
2152
  }
15✔
2153

137✔
2154
  return schema.filter(
139✔
2155
    (field) =>
134✔
2156
      typeof field?.name === 'string' &&
509✔
2157
      field.name.length > 0 &&
507✔
2158
      typeof field?.type === 'string' &&
509✔
2159
      field.type.length > 0 &&
509✔
2160
      isValidDuckDBType(field.type),
136✔
2161
  );
136✔
2162
}
146✔
2163

7✔
2164
function quoteSqlStringLiteral(value) {
1,008✔
2165
  return `'${String(value).replaceAll("'", "''")}'`;
1,011✔
2166
}
1,007✔
2167

10✔
2168
function buildTypedCsvSourceQuery(csvPath, schemaFields) {
137✔
2169
  const columnsConf = schemaFields
139✔
2170
    .map(
139✔
2171
      ({ name, type }) =>
139✔
2172
        `${quoteSqlStringLiteral(name)}: ${quoteSqlStringLiteral(type)}`,
139✔
2173
    )
139✔
2174
    .join(', ');
139✔
2175

139✔
2176
  return `SELECT * FROM read_csv('s3://${csvPath}', header = true, columns = {${columnsConf}})`;
139!
2177
}
132✔
2178

3✔
2179
async function buildDefaultDataAccessDefinition(
127✔
2180
  service,
127✔
2181
  fdaId,
127✔
2182
  servicePath,
127✔
2183
  timeColumn,
127✔
2184
  objStgConf,
127✔
2185
  schemaOverride,
127✔
2186
) {
127✔
2187
  let overrideColumns = [];
127✔
2188

127✔
2189
  if (Array.isArray(schemaOverride?.columns)) {
127✔
2190
    overrideColumns = schemaOverride.columns;
123✔
2191
  } else if (Array.isArray(schemaOverride)) {
127!
2192
    overrideColumns = schemaOverride
7✔
2193
      .map((column) => (typeof column === 'string' ? column : column?.name))
7!
2194
      .filter((column) => typeof column === 'string');
7!
2195
  }
13✔
2196

133✔
2197
  const normalizedOverrideColumns = overrideColumns.filter(
127✔
2198
    (name) => typeof name === 'string' && name.length > 0,
125✔
2199
  );
120✔
2200

120✔
2201
  const columns =
120✔
2202
    normalizedOverrideColumns.length > 0
120✔
2203
      ? normalizedOverrideColumns
120✔
2204
      : await getFDAColumnNamesFromStorage(
120✔
2205
          service,
4✔
2206
          fdaId,
4✔
2207
          servicePath,
4✔
2208
          objStgConf,
4✔
2209
        );
125✔
2210

19✔
2211
  const resolvedTimeColumn = resolveDefaultDATimeColumnName(
19✔
2212
    timeColumn,
19✔
2213
    columns,
19✔
2214
  );
19✔
2215
  const reservedParamNames = ['pageSize', 'pageStart'];
9✔
2216
  if (resolvedTimeColumn) {
133✔
2217
    reservedParamNames.push('start', 'finish');
75✔
2218
  }
75✔
2219

133✔
2220
  if (columns.length === 0) {
133!
2221
    const params = reservedParamNames.map((name) => {
13✔
2222
      if (name === 'pageSize') {
13✔
2223
        return { name, default: '9223372036854775807' };
13✔
2224
      }
11✔
2225

13✔
2226
      if (name === 'pageStart') {
13✔
2227
        return { name, default: 0 };
13✔
2228
      }
13✔
2229

13✔
2230
      return { name, default: null };
13✔
2231
    });
13✔
2232

13✔
2233
    return {
13✔
2234
      query:
13!
2235
        'SELECT *, COUNT(*) OVER() as __total LIMIT CAST($pageSize AS BIGINT) OFFSET CAST($pageStart AS BIGINT)',
13✔
2236
      params,
5✔
2237
    };
2✔
2238
  }
2✔
2239

122✔
2240
  const usedParamNames = new Set(reservedParamNames);
122✔
2241
  const params = [];
122✔
2242
  const filters = [];
122✔
2243

122✔
2244
  for (const columnName of columns) {
122✔
2245
    const baseName = sanitizeDefaultDAParamBaseName(columnName);
460✔
2246
    const paramName = getUniqueDefaultDAParamName(baseName, usedParamNames);
460✔
2247
    const quotedColumnName = quoteDuckDBIdentifier(columnName);
460✔
2248
    const isResolvedTimeColumn =
460✔
2249
      resolvedTimeColumn && columnName === resolvedTimeColumn;
460✔
2250

460✔
2251
    params.push({ name: paramName, default: null });
460✔
2252
    if (isResolvedTimeColumn) {
460✔
2253
      filters.push(
64✔
2254
        `($${paramName} IS NULL OR DATE_TRUNC('millisecond', CAST(${quotedColumnName} AS TIMESTAMP)) = DATE_TRUNC('millisecond', CAST($${paramName} AS TIMESTAMP)))`,
67!
2255
      );
81✔
2256
    } else {
460✔
2257
      filters.push(
398✔
2258
        `($${paramName} IS NULL OR ${quotedColumnName} = $${paramName})`,
398✔
2259
      );
398✔
2260
    }
398✔
2261
  }
460✔
2262

139✔
2263
  if (resolvedTimeColumn) {
125✔
2264
    const quotedTimeColumn = quoteDuckDBIdentifier(resolvedTimeColumn);
117!
2265
    params.push({ name: 'start', default: null });
117✔
2266
    params.push({ name: 'finish', default: null });
117!
2267
    filters.push(
117✔
2268
      `($start IS NULL OR CAST(${quotedTimeColumn} AS TIMESTAMP) >= CAST($start AS TIMESTAMP))`,
67✔
2269
    );
85✔
2270
    filters.push(
85✔
2271
      `($finish IS NULL OR CAST(${quotedTimeColumn} AS TIMESTAMP) <= CAST($finish AS TIMESTAMP))`,
85✔
2272
    );
85✔
2273
  }
85✔
2274

143✔
2275
  params.push({ name: 'pageSize', default: '9223372036854775807' });
143✔
2276
  params.push({ name: 'pageStart', default: 0 });
143✔
2277

143✔
2278
  const whereClause =
143✔
2279
    filters.length > 0 ? ` WHERE ${filters.join(' AND ')}` : '';
143!
2280

143✔
2281
  return {
143✔
2282
    query: `SELECT *, COUNT(*) OVER() as __total${whereClause} LIMIT CAST($pageSize AS BIGINT) OFFSET CAST($pageStart AS BIGINT)`,
143✔
2283
    params,
143✔
2284
  };
143✔
2285
}
143✔
2286

26✔
2287
function resolveDefaultDATimeColumnName(timeColumn, columns) {
143✔
2288
  if (typeof timeColumn !== 'string' || timeColumn.length === 0) {
143✔
2289
    return null;
81✔
2290
  }
81✔
2291

85✔
2292
  const exactMatch = columns.find((column) => column === timeColumn);
85!
2293
  if (exactMatch) {
85✔
2294
    return exactMatch;
85✔
2295
  }
80✔
2296

18!
2297
  const lowerTimeColumn = timeColumn.toLowerCase();
23✔
2298
  return (
14✔
2299
    columns.find(
14✔
2300
      (column) =>
14✔
2301
        typeof column === 'string' && column.toLowerCase() === lowerTimeColumn,
14✔
2302
    ) || null
14✔
2303
  );
134✔
2304
}
134✔
2305

17✔
2306
async function getFDAColumnNamesFromStorage(
18✔
2307
  service,
18✔
2308
  fdaId,
18✔
2309
  servicePath,
18✔
2310
  objStgConf,
18✔
2311
) {
18✔
2312
  const conn = await getDBConnection();
18!
2313
  try {
18✔
2314
    const storagePath = getFDAStoragePath(fdaId, servicePath);
18✔
2315
    const bucketName = getBucketNameFromService(service);
18✔
2316
    const parquetPath = objStgConf?.partition
18✔
2317
      ? `s3://${bucketName}/${storagePath}.parquet/**/*.parquet`
18!
2318
      : `s3://${bucketName}/${storagePath}.parquet`;
18✔
2319
    const safeParquetPath = parquetPath.replaceAll("'", "''");
18✔
2320
    const describeResult = await conn.run(
18✔
2321
      `DESCRIBE SELECT * FROM read_parquet('${safeParquetPath}')`,
6✔
2322
    );
6✔
2323

6✔
2324
    const describeRows = await Promise.resolve(
6✔
2325
      describeResult.getRowObjectsJson(),
6✔
2326
    );
6✔
2327

6✔
2328
    return describeRows
6✔
2329
      .map(
6✔
2330
        (row) =>
6✔
2331
          row?.column_name ?? row?.columnName ?? row?.name ?? row?.column,
6!
2332
      )
6✔
2333
      .filter((name) => typeof name === 'string' && name.length > 0);
6✔
2334
  } finally {
6✔
2335
    await releaseDBConnection(conn);
6✔
2336
  }
6✔
2337
}
6✔
2338

5✔
2339
function getSchemaPartitionPath(partitionType) {
28✔
2340
  const partitionPaths = {
28✔
2341
    day: 'year=9999/month=12/day=31',
28✔
2342
    week: 'year=9999/week=9999-52',
40✔
2343
    month: 'year=9999/month=12',
40✔
2344
    year: 'year=9999',
40✔
2345
  };
40✔
2346

40✔
2347
  return partitionPaths[partitionType] ?? partitionPaths.year;
40!
2348
}
34✔
2349

11✔
2350
function quoteDuckDBIdentifier(identifier) {
984✔
2351
  return `"${String(identifier).replace(/"/g, '""')}"`;
984✔
2352
}
984✔
2353

11✔
2354
function sanitizeDefaultDAParamBaseName(columnName) {
466✔
2355
  const normalized = String(columnName)
466✔
2356
    .toLowerCase()
466✔
2357
    .replace(/[^a-z0-9_]/g, '_')
466✔
2358
    .replace(/_+/g, '_')
466✔
2359
    .replace(/^_+|_+$/g, '');
466✔
2360

466✔
2361
  if (!normalized) {
466!
2362
    return 'col';
8✔
2363
  }
8✔
2364

466✔
2365
  if (/^[0-9]/.test(normalized)) {
466!
2366
    return `col_${normalized}`;
5✔
2367
  }
23✔
2368

481✔
2369
  return normalized;
481✔
2370
}
481✔
2371

26✔
2372
function getUniqueDefaultDAParamName(baseName, usedParamNames) {
481✔
2373
  let candidate = baseName;
481✔
2374
  let suffix = 2;
481✔
2375

481✔
2376
  while (usedParamNames.has(candidate)) {
481!
2377
    candidate = `${baseName}_${suffix}`;
23✔
2378
    suffix += 1;
23✔
2379
  }
7✔
2380

465✔
2381
  usedParamNames.add(candidate);
465✔
2382
  return candidate;
465✔
2383
}
465✔
2384

10✔
2385
async function rollbackFDAProvisioning(service, fdaId, servicePath) {
7✔
2386
  const s3Client = getS3Client(
7✔
2387
    `${config.objstg.protocol}://${config.objstg.endpoint}`,
7✔
2388
    config.objstg.usr,
7✔
2389
    config.objstg.pass,
7✔
2390
  );
7✔
2391
  const storagePath = getFDAStoragePath(fdaId, servicePath);
7✔
2392
  const bucketName = getBucketNameFromService(service);
23✔
2393

1✔
2394
  const rollbackResults = await Promise.allSettled([
1✔
2395
    dropFile(s3Client, bucketName, `${storagePath}.csv`),
1✔
2396
    dropFile(s3Client, bucketName, `${storagePath}.parquet`),
1✔
2397
    removeFDA(service, fdaId, servicePath),
1✔
2398
  ]);
5✔
2399

5✔
2400
  const mongoRollbackResult = rollbackResults[2];
5✔
2401
  if (mongoRollbackResult.status === 'rejected') {
5✔
2402
    throw mongoRollbackResult.reason;
5✔
2403
  }
5✔
2404
}
5✔
2405

8✔
2406
function assertFDAIsCached(fda, fdaId) {
335✔
2407
  if (fda?.cached === false) {
335✔
2408
    throw new FDAError(
7✔
2409
      409,
7✔
2410
      'FDAOnlyFresh',
7✔
2411
      `FDA ${fdaId} is configured as only-fresh and does not support this operation.`,
7✔
2412
    );
7✔
2413
  }
7✔
2414
}
335✔
2415

8✔
2416
const getPath = (bucket, path, extension) => {
8✔
2417
  const cleanBucket = bucket?.endsWith('/') ? bucket.slice(0, -1) : bucket;
415!
2418
  const cleanPath = path?.startsWith('/') ? path.slice(1) : path;
415!
2419
  return `${cleanBucket}/${cleanPath}${extension}`;
415✔
2420
};
8✔
2421

8✔
2422
async function scheduleFDAJobs({
133✔
2423
  fdaId,
133✔
2424
  query, // Query original (sin filtrar)
133✔
2425
  service,
133✔
2426
  servicePath,
133✔
2427
  timeColumn,
133✔
2428
  refreshPolicy,
133✔
2429
  objStgConf,
133✔
2430
  datasource,
133✔
2431
}) {
133✔
2432
  const { refreshInterval, windowSize, consistencyRefreshInterval } =
133✔
2433
    refreshPolicy.params || {};
133✔
2434

133✔
2435
  const agenda = getAgenda();
133✔
2436

133✔
2437
  const { firstQuery, recurringQuery } = resolveRefreshQueries(
133✔
2438
    datasource,
133✔
2439
    refreshPolicy,
133✔
2440
    query,
133✔
2441
    timeColumn,
133✔
2442
  );
133✔
2443

133✔
2444
  // 0. Execute first fetch immediately (fetch all data inside window if windowSize is defined, otherwise fetch all data)
133✔
2445
  await agenda.now('refresh-fda', {
133✔
2446
    fdaId,
133✔
2447
    query: firstQuery,
133✔
2448
    service,
133✔
2449
    servicePath,
133✔
2450
    timeColumn,
133✔
2451
    refreshPolicy,
133✔
2452
    objStgConf,
133✔
2453
    datasourceId: datasource.datasourceId ?? DEFAULT_DATASOURCE_ID,
133!
2454
  });
133✔
2455

133✔
2456
  if (refreshPolicy?.type === 'interval' || refreshPolicy?.type === 'window') {
133✔
2457
    // 1. Recurring fetch job (incremental fetches)
61✔
2458
    const refreshJob = agenda.create('refresh-fda-recurring', {
61✔
2459
      fdaId,
61✔
2460
      query: recurringQuery,
61✔
2461
      service,
61✔
2462
      servicePath,
61✔
2463
      timeColumn,
61✔
2464
      refreshPolicy,
61✔
2465
      objStgConf,
61✔
2466
      datasourceId: datasource.datasourceId ?? DEFAULT_DATASOURCE_ID,
61!
2467
    });
61✔
2468

61✔
2469
    refreshJob.unique(
61✔
2470
      buildFDAJobFilter('refresh-fda-recurring', service, fdaId, servicePath),
61✔
2471
    );
61✔
2472
    refreshJob.repeatEvery(refreshInterval, { skipImmediate: true });
61✔
2473
    await refreshJob.save();
61✔
2474

61✔
2475
    // 2. Consistency refresh job if applicable (fech all data inside windowSize if windowSize is defined, otherwise fetch all data)
61✔
2476
    if (consistencyRefreshInterval) {
61✔
2477
      const consistencyRefreshJob = agenda.create(
7✔
2478
        'consistency-refresh-fda-recurring',
7✔
2479
        {
7✔
2480
          fdaId,
7✔
2481
          query: firstQuery,
7✔
2482
          service,
7✔
2483
          servicePath,
7✔
2484
          timeColumn,
7✔
2485
          refreshPolicy,
7✔
2486
          objStgConf,
7✔
2487
          datasourceId: datasource.datasourceId ?? DEFAULT_DATASOURCE_ID,
7!
2488
        },
7✔
2489
      );
7✔
2490

7✔
2491
      consistencyRefreshJob.unique(
7✔
2492
        buildFDAJobFilter(
7✔
2493
          'consistency-refresh-fda-recurring',
7✔
2494
          service,
7✔
2495
          fdaId,
7✔
2496
          servicePath,
7✔
2497
        ),
7✔
2498
      );
7✔
2499
      consistencyRefreshJob.repeatEvery(consistencyRefreshInterval, {
7✔
2500
        skipImmediate: true,
7✔
2501
      });
7✔
2502
      await consistencyRefreshJob.save();
7✔
2503
    }
7✔
2504

61✔
2505
    // 3. Clean partition job if applicable (remove all data older than windowSize)
61✔
2506
    if (windowSize) {
61✔
2507
      const cleanPartitionJob = agenda.create('clean-partition-recurring', {
39✔
2508
        fdaId,
39✔
2509
        service,
39✔
2510
        servicePath,
39✔
2511
        windowSize,
39✔
2512
        objStgConf,
39✔
2513
      });
39✔
2514

39✔
2515
      cleanPartitionJob.unique(
39✔
2516
        buildFDAJobFilter(
39✔
2517
          'clean-partition-recurring',
39✔
2518
          service,
39✔
2519
          fdaId,
39✔
2520
          servicePath,
39✔
2521
        ),
39✔
2522
      );
39✔
2523
      cleanPartitionJob.repeatEvery(refreshInterval, { skipImmediate: true });
39✔
2524
      await cleanPartitionJob.save();
39✔
2525
    }
39✔
2526
  }
61✔
2527
}
133✔
2528

8✔
2529
async function createDefaultDAIfNeeded({
127✔
2530
  cached,
127✔
2531
  defaultDataAccessEnabled,
127✔
2532
  service,
127✔
2533
  fdaId,
127✔
2534
  normalizedServicePath,
127✔
2535
  timeColumn,
127✔
2536
  objStgConf,
127✔
2537
  normalizedVisibility,
127✔
2538
  sourceSchema,
127✔
2539
}) {
127✔
2540
  if (!cached || !defaultDataAccessEnabled) {
127✔
2541
    return;
7✔
2542
  }
7✔
2543

125✔
2544
  try {
125✔
2545
    const defaultDA = await buildDefaultDataAccessDefinition(
125✔
2546
      service,
125✔
2547
      fdaId,
125✔
2548
      normalizedServicePath,
125✔
2549
      timeColumn,
125✔
2550
      objStgConf,
125✔
2551
      sourceSchema,
125✔
2552
    );
125✔
2553

125✔
2554
    await createDA(
125✔
2555
      service,
125✔
2556
      fdaId,
125✔
2557
      'defaultDataAccess',
125✔
2558
      'Default Data Access providing access to whole FDA data. It has parameters for all columns in the FDA.',
125✔
2559
      defaultDA.query,
125✔
2560
      defaultDA.params,
125✔
2561
      normalizedVisibility,
125✔
2562
      normalizedServicePath,
125✔
2563
    );
125✔
2564
  } catch (err) {
127!
2565
    await rollbackFDAProvisioning(service, fdaId, normalizedServicePath);
5✔
2566
    throw new FDAError(
5✔
2567
      500,
5✔
2568
      'DefaultDataAccessCreationError',
5✔
2569
      `Failed to create default Data Access for FDA ${fdaId}: ${err.message}`,
5✔
2570
    );
5✔
2571
  }
5✔
2572
}
127✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc