ultrastiching-cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,482 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, extname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { DuckDBInstance } from '@duckdb/node-api';
7
+ import { tableFromIPC } from 'apache-arrow';
8
+ import { buildPayload, endpoint } from './registry.js';
9
+ import { callByName, redactKey } from './http.js';
10
+ import { resolveKey } from './config.js';
11
+
12
+ export const SCHEMA_VERSION = 1;
13
+ export const MAX_BUFFERED_ROWS = 100000;
14
+ export const MAX_BUFFERED_JSON_BYTES = 64 * 1024 * 1024;
15
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
16
+ let datasetsCache;
17
+
18
+ export function datasets() {
19
+ if (!datasetsCache) {
20
+ const source = new URL('../../registry/marketdb-datasets.json', import.meta.url);
21
+ const packed = new URL('../registry/marketdb-datasets.json', import.meta.url);
22
+ let raw;
23
+ try { raw = readFileSync(fileURLToPath(source), 'utf8'); }
24
+ catch (error) {
25
+ if (error.code !== 'ENOENT') throw error;
26
+ raw = readFileSync(fileURLToPath(packed), 'utf8');
27
+ }
28
+ datasetsCache = JSON.parse(raw).datasets;
29
+ }
30
+ return datasetsCache;
31
+ }
32
+
33
+ export function dbPath(explicit, env = process.env, home = homedir()) {
34
+ const value = explicit ?? env.MERIDIAN_DB ?? '~/.meridian/marketdb.duckdb';
35
+ if (typeof value !== 'string' || !value.trim()) throw new Error('database path must be a non-empty string');
36
+ const expanded = value === '~' ? home : value.startsWith('~/') ? join(home, value.slice(2)) : value;
37
+ return resolve(expanded);
38
+ }
39
+
40
+ export function canonicalJson(value) {
41
+ const normalize = (item) => {
42
+ if (item instanceof Date) return item.toISOString().slice(0, 10);
43
+ if (Array.isArray(item)) return item.map(normalize);
44
+ if (item && typeof item === 'object') return Object.fromEntries(Object.keys(item).sort().filter((key) => item[key] !== undefined).map((key) => [key, normalize(item[key])]));
45
+ if (typeof item === 'number' && !Number.isFinite(item)) throw new TypeError('non-finite number is not JSON serializable');
46
+ if (typeof item === 'bigint' || item === undefined) throw new TypeError('value is not JSON serializable');
47
+ return item;
48
+ };
49
+ return JSON.stringify(normalize(value));
50
+ }
51
+
52
+ const sha = (raw) => createHash('sha256').update(raw, 'utf8').digest('hex');
53
+ let lastMicrosecond = 0;
54
+ function now() {
55
+ lastMicrosecond = Math.max(Date.now() * 1000, lastMicrosecond + 1);
56
+ const date = new Date(Math.floor(lastMicrosecond / 1000)).toISOString();
57
+ return `${date.slice(0, 20)}${String(lastMicrosecond % 1000000).padStart(6, '0')}+00:00`;
58
+ }
59
+ function quoted(value) {
60
+ if (typeof value !== 'string' || !IDENT.test(value)) throw new Error(`unsupported SQL identifier: ${value}`);
61
+ return `"${value.replaceAll('"', '""')}"`;
62
+ }
63
+ function sqlString(value) {
64
+ if (value.includes('\0')) throw new Error('NUL is not permitted in an export path');
65
+ return `'${value.replaceAll("'", "''")}'`;
66
+ }
67
+ function jsonSafe(value) {
68
+ if (typeof value === 'bigint') return value >= Number.MIN_SAFE_INTEGER && value <= Number.MAX_SAFE_INTEGER ? Number(value) : value.toString();
69
+ if (value instanceof Date) return value.toISOString().slice(0, 10);
70
+ if (Array.isArray(value)) return value.map(jsonSafe);
71
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, jsonSafe(v)]));
72
+ return value;
73
+ }
74
+
75
+ export class UnsupportedArrowValueError extends TypeError {
76
+ constructor(field, value) {
77
+ super(`Arrow field ${field} contains int64 ${value}, outside JavaScript's exact integer range`);
78
+ this.name = 'UnsupportedArrowValueError';
79
+ this.field = field;
80
+ this.value = value;
81
+ }
82
+ toDiagnostic() { return { type: this.name, field: this.field, value: this.value, message: this.message }; }
83
+ }
84
+
85
+ function exactArrowValue(value, field) {
86
+ if (typeof value === 'bigint') {
87
+ if (value < BigInt(Number.MIN_SAFE_INTEGER) || value > BigInt(Number.MAX_SAFE_INTEGER)) {
88
+ throw new UnsupportedArrowValueError(field, value.toString());
89
+ }
90
+ return Number(value);
91
+ }
92
+ if (value instanceof Date) return value.toISOString().slice(0, 10);
93
+ if (Array.isArray(value)) return value.map((item, index) => exactArrowValue(item, `${field}[${index}]`));
94
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, exactArrowValue(item, `${field}.${key}`)]));
95
+ return value;
96
+ }
97
+
98
+ async function withDb(path, readOnly, fn) {
99
+ if (!readOnly) mkdirSync(dirname(path), { recursive: true });
100
+ if (readOnly && !existsSync(path)) throw new Error(`database does not exist: ${path}`);
101
+ const instance = await DuckDBInstance.create(path, readOnly ? { access_mode: 'READ_ONLY' } : undefined);
102
+ const con = await instance.connect();
103
+ try { return await fn(con); }
104
+ finally { con.closeSync(); instance.closeSync(); }
105
+ }
106
+
107
+ const rows = async (con, sql, params = []) => jsonSafe((await con.runAndReadAll(sql, params)).getRowsJS());
108
+ const objects = async (con, sql, params = []) => jsonSafe((await con.runAndReadAll(sql, params)).getRowObjectsJS());
109
+ const run = async (con, sql, params = []) => con.run(sql, params);
110
+
111
+ function catalogTypes(name) {
112
+ const meta = endpoint(name, 'http');
113
+ if (!meta) throw new Error(`endpoint missing from registry: ${name}`);
114
+ const fields = new Map();
115
+ for (const item of meta.response?.fields ?? []) {
116
+ quoted(item.name);
117
+ const kind = String(item.type ?? '').toLowerCase();
118
+ let dtype = 'VARCHAR';
119
+ if (['float32', 'float64', 'double', 'number'].includes(kind)) dtype = 'DOUBLE';
120
+ else if (['int', 'int32', 'int64', 'integer', 'long'].includes(kind)) dtype = 'BIGINT';
121
+ else if (['bool', 'boolean'].includes(kind)) dtype = 'BOOLEAN';
122
+ else if (kind === 'date') dtype = 'DATE';
123
+ const source = ['string', 'float32', 'float64', 'double', 'number', 'int', 'int32', 'int64', 'integer', 'long', 'bool', 'boolean', 'date'].includes(kind) ? 'catalog' : 'unresolved';
124
+ fields.set(item.name, [dtype, source]);
125
+ }
126
+ return fields;
127
+ }
128
+
129
+ function runtimeType(value) {
130
+ if (value === null || value === undefined) return null;
131
+ if (typeof value === 'boolean') return 'BOOLEAN';
132
+ if (typeof value === 'number' && Number.isFinite(value)) return 'DOUBLE';
133
+ if (value instanceof Date) return 'DATE';
134
+ if (typeof value === 'string') return 'VARCHAR';
135
+ throw new TypeError('nested response fields require explicit catalog typing');
136
+ }
137
+ function compatible(expected, runtime, value) {
138
+ if (expected === runtime) return true;
139
+ if (expected === 'BIGINT' && runtime === 'DOUBLE' && Number.isInteger(value)) return true;
140
+ if (expected === 'DATE' && runtime === 'VARCHAR' && /^\d{4}-\d{2}-\d{2}$/.test(value)) return true;
141
+ return false;
142
+ }
143
+
144
+ export class MeridianResultError extends Error {
145
+ constructor(body, requestId, key) {
146
+ super('UltraStiching API returned a JSON result error');
147
+ this.name = 'MeridianResultError';
148
+ this.body = body;
149
+ this.requestId = requestId;
150
+ this.errorCode = typeof body?.error === 'string' ? body.error : body?.error?.code;
151
+ this.key = key;
152
+ }
153
+ toDiagnostic() {
154
+ return { type: this.name, message: this.message, error: redactKey(this.errorCode, this.key),
155
+ request_id: redactKey(this.requestId, this.key), raw_body: redactKey(canonicalJson(this.body), this.key) };
156
+ }
157
+ }
158
+
159
+ export class MeridianResponseShapeError extends Error {
160
+ constructor(message, body, requestId, key) {
161
+ super(message);
162
+ this.name = 'MeridianResponseShapeError';
163
+ this.body = body;
164
+ this.requestId = requestId;
165
+ this.key = key;
166
+ }
167
+ toDiagnostic() {
168
+ return { type: this.name, message: this.message, request_id: redactKey(this.requestId, this.key),
169
+ raw_body: redactKey(canonicalJson(this.body), this.key) };
170
+ }
171
+ }
172
+
173
+ function decodeResponse(response, key) {
174
+ if (response.binary) {
175
+ const table = tableFromIPC(response.data);
176
+ const data = [];
177
+ for (const row of table) data.push(exactArrowValue(row.toJSON(), 'row'));
178
+ return { data, pagination: null };
179
+ }
180
+ const body = response.data;
181
+ if (body && typeof body === 'object' && body.error != null) throw new MeridianResultError(body, response.requestId, key);
182
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
183
+ throw new MeridianResponseShapeError('JSON result is not a successful data_frame', body, response.requestId, key);
184
+ }
185
+ if (!Array.isArray(body.data_frame) || !body.data_frame.every((item) => item && typeof item === 'object' && !Array.isArray(item))) {
186
+ throw new MeridianResponseShapeError('JSON result has no data_frame object list', body, response.requestId, key);
187
+ }
188
+ return { data: body.data_frame, pagination: body.pagination && typeof body.pagination === 'object' && !Array.isArray(body.pagination) ? body.pagination : null };
189
+ }
190
+
191
+ function proof(pagination, request, count) {
192
+ if (!pagination) return null;
193
+ const { offset, limit, total } = pagination;
194
+ if (![offset, limit, total].every(Number.isSafeInteger) || offset < 0 || limit <= 0 || total < 0) return null;
195
+ if (request.offset !== offset || request.limit !== limit) return null;
196
+ if (count !== Math.max(0, Math.min(limit, total - offset))) return null;
197
+ return { offset, limit, total };
198
+ }
199
+
200
+ function errorDetails(error, key) {
201
+ const diagnostic = error.toDiagnostic?.() ?? { type: error.name, message: error.message };
202
+ return redactKey(canonicalJson(diagnostic), key);
203
+ }
204
+
205
+ export class MarketDB {
206
+ constructor({ path, env, home, request = callByName, requestOptions = {} } = {}) {
207
+ this.path = dbPath(path, env, home);
208
+ this.request = request;
209
+ this.requestOptions = requestOptions;
210
+ }
211
+
212
+ async init() {
213
+ await withDb(this.path, false, async (con) => {
214
+ await run(con, 'CREATE TABLE IF NOT EXISTS _marketdb_meta (schema_version INTEGER PRIMARY KEY, created_at VARCHAR NOT NULL)');
215
+ const versions = await rows(con, 'SELECT schema_version FROM _marketdb_meta');
216
+ if (versions.length && (versions.length !== 1 || versions[0][0] !== SCHEMA_VERSION)) throw new Error(`unsupported marketdb schema version: ${JSON.stringify(versions)}`);
217
+ if (!versions.length) await run(con, 'INSERT INTO _marketdb_meta VALUES (?,?)', [SCHEMA_VERSION, now()]);
218
+ await run(con, 'CREATE TABLE IF NOT EXISTS _marketdb_columns (dataset VARCHAR NOT NULL, column_name VARCHAR NOT NULL, duckdb_type VARCHAR NOT NULL, source VARCHAR NOT NULL, PRIMARY KEY (dataset, column_name))');
219
+ await run(con, 'CREATE TABLE IF NOT EXISTS _sync_runs (run_id VARCHAR PRIMARY KEY, dataset VARCHAR NOT NULL, endpoint VARCHAR NOT NULL, params_json VARCHAR NOT NULL, params_sha256 VARCHAR NOT NULL, status VARCHAR NOT NULL, started_at VARCHAR NOT NULL, finished_at VARCHAR, error_json VARCHAR, received_rows BIGINT NOT NULL, written_rows BIGINT NOT NULL, chunk_count INTEGER NOT NULL)');
220
+ await run(con, 'CREATE TABLE IF NOT EXISTS _sync_chunks (run_id VARCHAR NOT NULL, chunk_index INTEGER NOT NULL, request_json VARCHAR NOT NULL, request_sha256 VARCHAR NOT NULL, status VARCHAR NOT NULL, received_rows BIGINT NOT NULL, completeness_json VARCHAR, error_json VARCHAR, PRIMARY KEY (run_id, chunk_index))');
221
+ });
222
+ return { db: this.path, schema_version: SCHEMA_VERSION, datasets: Object.keys(datasets()).sort() };
223
+ }
224
+
225
+ async recordChunk(runId, index, request, status, count, evidence, error) {
226
+ const raw = canonicalJson(request);
227
+ await withDb(this.path, false, (con) => run(con, 'INSERT INTO _sync_chunks VALUES (?,?,?,?,?,?,?,?)',
228
+ [runId, index, raw, sha(raw), status, count, evidence === null ? null : canonicalJson(evidence), error ?? null]));
229
+ }
230
+
231
+ async finish(runId, status, received, written, chunks, error = null) {
232
+ await withDb(this.path, false, (con) => run(con,
233
+ 'UPDATE _sync_runs SET status=?,finished_at=?,error_json=?,received_rows=?,written_rows=?,chunk_count=? WHERE run_id=?',
234
+ [status, now(), error, received, written, chunks, runId]));
235
+ }
236
+
237
+ async sync(dataset, overrides = {}) {
238
+ const spec = datasets()[dataset];
239
+ if (!spec) throw new Error(`unknown dataset: ${dataset}`);
240
+ await this.init();
241
+ const params = { ...spec.kwargs, ...overrides };
242
+ if (Object.hasOwn(params, 'limit') && (!Number.isSafeInteger(params.limit) || params.limit <= 0)) throw new Error('limit must be a positive integer');
243
+ if (params.offset !== undefined && params.offset !== 0) throw new Error('complete pagination must start at offset 0');
244
+ const raw = canonicalJson(params);
245
+ const runId = randomUUID();
246
+ await withDb(this.path, false, (con) => run(con, 'INSERT INTO _sync_runs VALUES (?,?,?,?,?,?,?,?,?,?,?,?)',
247
+ [runId, dataset, spec.endpoint, raw, sha(raw), 'running', now(), null, null, 0, 0, 0]));
248
+ let count = 0;
249
+ let bufferedBytes = 0;
250
+ let page = 0;
251
+ let total = null;
252
+ const allRows = [];
253
+ const pageParams = { ...params };
254
+ if (Object.hasOwn(pageParams, 'limit')) pageParams.offset = 0;
255
+ let actualKey = this.requestOptions.key;
256
+ try {
257
+ while (true) {
258
+ const request = { ...pageParams };
259
+ const index = page++;
260
+ let fetched;
261
+ let verified;
262
+ let pagination;
263
+ try {
264
+ const meta = endpoint(spec.endpoint, 'http');
265
+ if (this.request === callByName) actualKey = resolveKey(actualKey);
266
+ const response = await this.request(spec.endpoint, buildPayload(meta, request), { ...this.requestOptions, key: actualKey });
267
+ ({ data: fetched, pagination } = decodeResponse(response, actualKey));
268
+ count += fetched.length;
269
+ verified = proof(pagination, request, fetched.length);
270
+ await this.recordChunk(runId, index, request, verified ? 'complete' : 'incomplete', fetched.length,
271
+ pagination === null ? null : { server_pagination: pagination, verified });
272
+ } catch (error) {
273
+ await this.recordChunk(runId, index, request, 'failed', 0, null, errorDetails(error, actualKey));
274
+ throw error;
275
+ }
276
+ if (!verified) {
277
+ await this.finish(runId, 'incomplete', count, 0, page);
278
+ return { table: dataset, endpoint: spec.endpoint, status: 'incomplete', run_id: runId, received_rows: count, written_rows: 0, chunks: page, reason: 'no verified pagination coverage' };
279
+ }
280
+ if (total === null) total = verified.total;
281
+ else if (total !== verified.total) {
282
+ await this.finish(runId, 'incomplete', count, 0, page);
283
+ return { table: dataset, endpoint: spec.endpoint, status: 'incomplete', run_id: runId, received_rows: count, written_rows: 0, chunks: page, reason: 'pagination total changed' };
284
+ }
285
+ for (const row of fetched) bufferedBytes += Buffer.byteLength(canonicalJson(row), 'utf8');
286
+ if (total > MAX_BUFFERED_ROWS || count > MAX_BUFFERED_ROWS || bufferedBytes > MAX_BUFFERED_JSON_BYTES) {
287
+ throw new RangeError("verified result exceeds marketdb's in-memory buffer limit (100000 rows or 64 MiB JSON)");
288
+ }
289
+ for (const row of fetched) allRows.push(row);
290
+ const nextOffset = verified.offset + fetched.length;
291
+ if (nextOffset >= total) break;
292
+ pageParams.offset = nextOffset;
293
+ }
294
+ const written = await this.writeComplete(dataset, spec, allRows, runId, page);
295
+ return { table: dataset, endpoint: spec.endpoint, status: 'complete', run_id: runId, received_rows: count, written_rows: written, chunks: page };
296
+ } catch (error) {
297
+ await this.finish(runId, 'failed', count, 0, page, errorDetails(error, actualKey));
298
+ throw error;
299
+ }
300
+ }
301
+
302
+ async writeComplete(dataset, spec, data, runId, chunks) {
303
+ const fields = catalogTypes(spec.endpoint);
304
+ for (const row of data) for (const [name, value] of Object.entries(row)) {
305
+ quoted(name);
306
+ const runtime = runtimeType(value);
307
+ const prior = fields.get(name);
308
+ if (!prior) fields.set(name, [runtime ?? 'VARCHAR', runtime ? 'runtime' : 'unresolved']);
309
+ else if (prior[1] === 'unresolved' && runtime) fields.set(name, [runtime, 'runtime']);
310
+ else if (prior[1] === 'runtime' && runtime && prior[0] !== runtime) throw new TypeError(`conflicting response type for ${dataset}.${name}`);
311
+ else if (prior[1] === 'catalog' && runtime && !compatible(prior[0], runtime, value)) throw new TypeError(`response type disagrees with catalog for ${dataset}.${name}`);
312
+ }
313
+ for (const key of spec.keys) if (!fields.has(key)) throw new Error(`key column missing from response/catalog: ${key}`);
314
+ const seen = new Set();
315
+ const prepared = [];
316
+ for (const row of data) {
317
+ if (spec.keys.some((key) => row[key] === undefined || row[key] === null)) throw new Error(`response row is missing a key for ${dataset}`);
318
+ const rowKey = canonicalJson(spec.keys.map((key) => row[key]));
319
+ if (seen.has(rowKey)) throw new Error(`duplicate key in response for ${dataset}: ${rowKey}`);
320
+ seen.add(rowKey);
321
+ prepared.push([rowKey, row]);
322
+ }
323
+ return withDb(this.path, false, async (con) => {
324
+ await run(con, 'BEGIN TRANSACTION');
325
+ try {
326
+ const old = await rows(con, 'SELECT column_name,duckdb_type,source FROM _marketdb_columns WHERE dataset=?', [dataset]);
327
+ const existing = new Map(old.map(([name, dtype, source]) => [name, [dtype, source]]));
328
+ for (const [name, prior] of existing) {
329
+ const current = fields.get(name);
330
+ if (!current) fields.set(name, prior);
331
+ else if (prior[1] !== 'unresolved' && (prior[0] !== current[0] || prior[1] !== current[1])) throw new TypeError(`stored column type differs for ${dataset}.${name}`);
332
+ }
333
+ const table = quoted(dataset);
334
+ await run(con, `CREATE TABLE IF NOT EXISTS ${table} (_row_key VARCHAR PRIMARY KEY, _raw_json VARCHAR NOT NULL, _updated_at VARCHAR NOT NULL, _run_id VARCHAR NOT NULL)`);
335
+ for (const [name, [dtype, source]] of fields) {
336
+ if (name.startsWith('_')) throw new Error(`response field conflicts with reserved column: ${name}`);
337
+ if (!existing.has(name)) {
338
+ await run(con, `ALTER TABLE ${table} ADD COLUMN ${quoted(name)} ${dtype}`);
339
+ await run(con, 'INSERT INTO _marketdb_columns VALUES (?,?,?,?)', [dataset, name, dtype, source]);
340
+ } else if (existing.get(name)[1] === 'unresolved' && source !== 'unresolved') {
341
+ await run(con, `ALTER TABLE ${table} ALTER COLUMN ${quoted(name)} SET DATA TYPE ${dtype}`);
342
+ await run(con, 'UPDATE _marketdb_columns SET duckdb_type=?,source=? WHERE dataset=? AND column_name=?', [dtype, source, dataset, name]);
343
+ }
344
+ }
345
+ const columns = ['_row_key', '_raw_json', '_updated_at', '_run_id', ...fields.keys()];
346
+ const sql = `INSERT INTO ${table} (${columns.map(quoted).join(',')}) VALUES (${columns.map(() => '?').join(',')})`;
347
+ for (const [rowKey, row] of prepared) {
348
+ await run(con, `DELETE FROM ${table} WHERE _row_key=?`, [rowKey]);
349
+ await run(con, sql, [rowKey, canonicalJson(row), now(), runId, ...[...fields].map(([name, [dtype]]) => {
350
+ const value = row[name] ?? null;
351
+ if (value === null) return null;
352
+ if (dtype === 'VARCHAR' && typeof value !== 'string') throw new TypeError(`expected string response value for ${dataset}.${name}`);
353
+ return value instanceof Date ? value.toISOString().slice(0, 10) : value;
354
+ })]);
355
+ }
356
+ await run(con, "UPDATE _sync_runs SET status='complete',finished_at=?,received_rows=?,written_rows=?,chunk_count=? WHERE run_id=?", [now(), data.length, prepared.length, chunks, runId]);
357
+ await run(con, 'COMMIT');
358
+ return prepared.length;
359
+ } catch (error) { await run(con, 'ROLLBACK'); throw error; }
360
+ });
361
+ }
362
+
363
+ async status() {
364
+ await this.init();
365
+ return withDb(this.path, true, async (con) => {
366
+ const tables = [];
367
+ for (const name of Object.keys(datasets()).sort()) {
368
+ if ((await rows(con, "SELECT 1 FROM information_schema.tables WHERE table_schema='main' AND table_name=?", [name])).length) {
369
+ tables.push({ table: name, rows: (await rows(con, `SELECT COUNT(*) FROM ${quoted(name)}`))[0][0] });
370
+ }
371
+ }
372
+ const recent_syncs = await objects(con, 'SELECT run_id,dataset,endpoint,status,received_rows,written_rows,chunk_count FROM _sync_runs ORDER BY started_at DESC LIMIT 20');
373
+ return { db: this.path, schema_version: SCHEMA_VERSION, tables, recent_syncs };
374
+ });
375
+ }
376
+
377
+ async validate() {
378
+ await this.init();
379
+ return withDb(this.path, true, async (con) => {
380
+ const issues = [];
381
+ const completeIds = new Set((await rows(con, "SELECT run_id FROM _sync_runs WHERE status='complete'")).map((item) => item[0]));
382
+ const latest = new Map();
383
+ for (const item of await objects(con, 'SELECT run_id,dataset FROM _sync_runs ORDER BY started_at DESC')) {
384
+ if (!latest.has(item.dataset)) latest.set(item.dataset, item.run_id);
385
+ }
386
+ const runs = await objects(con, 'SELECT run_id,dataset,params_json,params_sha256,status,chunk_count FROM _sync_runs');
387
+ for (const item of runs) {
388
+ if (sha(item.params_json) !== item.params_sha256) issues.push({ run_id: item.run_id, issue: 'params_hash_mismatch' });
389
+ else { try { const data = JSON.parse(item.params_json); if (!data || typeof data !== 'object' || Array.isArray(data)) throw Error(); }
390
+ catch { issues.push({ run_id: item.run_id, issue: 'invalid_params_json' }); } }
391
+ const chunks = await objects(con, 'SELECT chunk_index,request_json,request_sha256,status,received_rows,completeness_json FROM _sync_chunks WHERE run_id=? ORDER BY chunk_index', [item.run_id]);
392
+ if (chunks.length !== item.chunk_count || chunks.some((chunk, index) => chunk.chunk_index !== index)) issues.push({ run_id: item.run_id, issue: 'chunk_sequence_mismatch' });
393
+ let nextOffset = 0;
394
+ let expectedTotal = null;
395
+ for (const chunk of chunks) {
396
+ if (sha(chunk.request_json) !== chunk.request_sha256) issues.push({ run_id: item.run_id, chunk_index: chunk.chunk_index, issue: 'request_hash_mismatch' });
397
+ let request = null;
398
+ try { request = JSON.parse(chunk.request_json); }
399
+ catch { issues.push({ run_id: item.run_id, chunk_index: chunk.chunk_index, issue: 'invalid_request_json' }); }
400
+ let verified = null;
401
+ let checked = null;
402
+ try {
403
+ const evidence = chunk.completeness_json === null ? null : JSON.parse(chunk.completeness_json);
404
+ verified = evidence?.verified;
405
+ checked = request && typeof request === 'object' && !Array.isArray(request)
406
+ ? proof(evidence?.server_pagination, request, chunk.received_rows) : null;
407
+ } catch { /* invalid evidence is reported below for complete chunks */ }
408
+ if (chunk.status === 'complete') {
409
+ if (!checked || canonicalJson(checked) !== canonicalJson(verified)) {
410
+ issues.push({ run_id: item.run_id, chunk_index: chunk.chunk_index, issue: 'invalid_completeness_evidence' });
411
+ } else if (checked.offset !== nextOffset || (expectedTotal !== null && checked.total !== expectedTotal)) {
412
+ issues.push({ run_id: item.run_id, chunk_index: chunk.chunk_index, issue: 'pagination_gap_or_total_changed' });
413
+ } else { expectedTotal = checked.total; nextOffset += chunk.received_rows; }
414
+ }
415
+ }
416
+ if (item.status === 'complete' && (!chunks.length || chunks.some((chunk) => chunk.status !== 'complete'))) issues.push({ run_id: item.run_id, issue: 'run_without_complete_chunks' });
417
+ else if (item.status === 'complete' && expectedTotal !== null && nextOffset !== expectedTotal) issues.push({ run_id: item.run_id, issue: 'incomplete_pagination_coverage' });
418
+ if (['incomplete', 'failed', 'running'].includes(item.status) && latest.get(item.dataset) === item.run_id) issues.push({ run_id: item.run_id, dataset: item.dataset, issue: item.status });
419
+ }
420
+ for (const name of Object.keys(datasets())) {
421
+ if (!(await rows(con, "SELECT 1 FROM information_schema.tables WHERE table_schema='main' AND table_name=?", [name])).length) continue;
422
+ const columns = new Map((await rows(con, "SELECT column_name,data_type FROM information_schema.columns WHERE table_schema='main' AND table_name=?", [name])));
423
+ for (const [column, dtype] of await rows(con, 'SELECT column_name,duckdb_type FROM _marketdb_columns WHERE dataset=?', [name])) {
424
+ if (columns.get(column) !== dtype) issues.push({ table: name, column, issue: 'column_type_mismatch' });
425
+ }
426
+ for (const [rowKey, raw, rowRunId] of await rows(con, `SELECT _row_key,_raw_json,_run_id FROM ${quoted(name)}`)) {
427
+ try {
428
+ const payload = JSON.parse(raw);
429
+ if (canonicalJson(datasets()[name].keys.map((key) => payload[key])) !== rowKey) throw new Error('row key mismatch');
430
+ } catch { issues.push({ table: name, row_key: rowKey, issue: 'invalid_row_key_or_json' }); }
431
+ if (!completeIds.has(rowRunId)) issues.push({ table: name, row_key: rowKey, issue: 'row_references_noncomplete_run' });
432
+ }
433
+ }
434
+ return { db: this.path, ok: issues.length === 0, issues };
435
+ });
436
+ }
437
+
438
+ async repair(dataset) {
439
+ await this.init();
440
+ if (dataset !== undefined && !datasets()[dataset]) throw new Error(`unknown dataset: ${dataset}`);
441
+ const runs = await withDb(this.path, true, (con) => objects(con,
442
+ 'SELECT dataset,params_json,params_sha256,status FROM _sync_runs ORDER BY started_at DESC'));
443
+ const visited = new Set();
444
+ const repaired = [];
445
+ for (const item of runs) {
446
+ if (visited.has(item.dataset) || (dataset && item.dataset !== dataset)) continue;
447
+ visited.add(item.dataset);
448
+ if (!['failed', 'incomplete'].includes(item.status)) continue;
449
+ if (sha(item.params_json) !== item.params_sha256) throw new Error(`stored request parameters hash mismatch for ${item.dataset}`);
450
+ let params;
451
+ try { params = JSON.parse(item.params_json); }
452
+ catch (cause) { throw new Error(`invalid stored request parameters for ${item.dataset}`, { cause }); }
453
+ if (!params || typeof params !== 'object' || Array.isArray(params)) throw new Error(`stored request parameters are not an object for ${item.dataset}`);
454
+ repaired.push(await this.sync(item.dataset, params));
455
+ }
456
+ return { repaired };
457
+ }
458
+
459
+ async sql(query, limit) {
460
+ if (typeof query !== 'string' || !/^\s*(SELECT|WITH)\b/i.test(query)) throw new Error('only SELECT/WITH queries are supported');
461
+ const stripped = query.trim().replace(/;+$/, '').trim();
462
+ if (stripped.includes(';')) throw new Error('multiple SQL statements are not supported');
463
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit <= 0)) throw new Error('limit must be a positive integer');
464
+ return withDb(this.path, true, async (con) => {
465
+ const reader = await con.runAndReadAll(stripped);
466
+ const all = reader.getRowsJson();
467
+ const values = limit ? all.slice(0, limit) : all;
468
+ return { columns: reader.columnNames(), rows: values, row_count: values.length };
469
+ });
470
+ }
471
+
472
+ async export(table, path, format) {
473
+ if (!datasets()[table]) throw new Error(`unknown dataset: ${table}`);
474
+ if (typeof path !== 'string' || !path) throw new Error('export path is required');
475
+ const target = dbPath(path, {}, homedir());
476
+ const fmt = (format ?? (extname(target).slice(1) || 'csv')).toLowerCase();
477
+ if (!['csv', 'parquet'].includes(fmt)) throw new Error('format must be csv or parquet');
478
+ mkdirSync(dirname(target), { recursive: true });
479
+ await withDb(this.path, true, (con) => run(con, `COPY (SELECT * FROM ${quoted(table)}) TO ${sqlString(target)} (FORMAT ${fmt.toUpperCase()})`));
480
+ return { table, path: target, format: fmt };
481
+ }
482
+ }
@@ -0,0 +1,91 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ let cached;
5
+ export function loadRegistry() {
6
+ if (!cached) {
7
+ // The source checkout reads the shared generated registry. `prepack` copies it
8
+ // into the package so an installed CLI has the identical snapshot.
9
+ const source = new URL('../../registry/endpoints.json', import.meta.url);
10
+ const packed = new URL('../registry/endpoints.json', import.meta.url);
11
+ let data;
12
+ try { data = readFileSync(fileURLToPath(source), 'utf8'); }
13
+ catch (error) {
14
+ if (error.code !== 'ENOENT') throw error;
15
+ data = readFileSync(fileURLToPath(packed), 'utf8');
16
+ }
17
+ cached = JSON.parse(data);
18
+ }
19
+ return cached;
20
+ }
21
+
22
+ export function endpoint(name, protocol) {
23
+ return loadRegistry().endpoints.find((item) => item.name === name && (!protocol || item.protocol === protocol));
24
+ }
25
+
26
+ export function searchEndpoints({ search = '', binary = false } = {}) {
27
+ const term = search.toLocaleLowerCase();
28
+ return loadRegistry().endpoints.filter((item) =>
29
+ (!binary || item.response?.return_format === 'ARROW_IPC') &&
30
+ (!term || [item.name, item.title, item.description, ...(item.categories ?? [])]
31
+ .some((value) => String(value).toLocaleLowerCase().includes(term))));
32
+ }
33
+
34
+ export function buildPayload(meta, values) {
35
+ if (!meta || !values || typeof values !== 'object' || Array.isArray(values)) {
36
+ throw new TypeError('endpoint and parameter object are required');
37
+ }
38
+ const names = new Set(meta.parameters ?? []);
39
+ for (const name of Object.keys(values)) {
40
+ if (!names.has(name)) throw new Error(`${meta.name} has no parameter ${name}; run ultrastiching info ${meta.name}`);
41
+ }
42
+ const required = [...names].filter((name) => !(meta.optional_parameters ?? []).includes(name));
43
+ const missing = required.filter((name) => values[name] === undefined);
44
+ if (missing.length) throw new Error(`${meta.name} requires: ${missing.join(', ')}`);
45
+ const retained = new Set(meta.retained_objects ?? []);
46
+ const walk = (node, path = '') => {
47
+ if (node && typeof node === 'object' && '$param' in node) {
48
+ const value = values[node.$param];
49
+ if (value !== undefined && path.endsWith('.symbols') && !Array.isArray(value)) {
50
+ return typeof value === 'string' ? value.split(',').map((part) => part.trim()) : [value];
51
+ }
52
+ return value;
53
+ }
54
+ if (Array.isArray(node)) return node.map((child) => walk(child));
55
+ if (node && typeof node === 'object') {
56
+ const result = {};
57
+ for (const [key, child] of Object.entries(node)) {
58
+ const value = walk(child, path ? `${path}.${key}` : key);
59
+ if (value !== undefined) result[key] = value;
60
+ }
61
+ if (Object.keys(result).length || retained.has(path)) return result;
62
+ return undefined;
63
+ }
64
+ return node;
65
+ };
66
+ return walk(meta.payload) ?? {};
67
+ }
68
+
69
+ export function parseParameter(raw, field = {}) {
70
+ if (typeof raw !== 'string') return raw;
71
+ const value = raw.trim();
72
+ const type = (field.type ?? '').toLowerCase();
73
+ if (value.startsWith('{') || value.startsWith('[')) {
74
+ try { return JSON.parse(value); } catch { throw new Error(`Invalid JSON value for ${field.name}`); }
75
+ }
76
+ if (type.includes('boolean') || type === 'bool') {
77
+ if (value === 'true') return true;
78
+ if (value === 'false') return false;
79
+ throw new Error(`${field.name} must be true or false`);
80
+ }
81
+ if (type === 'integer' || type === 'int64' || type === 'number' || type === 'float64') {
82
+ const parsed = Number(value);
83
+ if (!Number.isFinite(parsed) || (type.startsWith('int') && !Number.isInteger(parsed))) {
84
+ throw new Error(`${field.name} must be a ${type}`);
85
+ }
86
+ return parsed;
87
+ }
88
+ if (type === 'array' || (type.endsWith('[]') && !type.includes('|'))) return value.split(',').map((part) => part.trim());
89
+ if (type.includes('string[]') && value.includes(',')) return value.split(',').map((part) => part.trim());
90
+ return raw;
91
+ }
@@ -0,0 +1,72 @@
1
+ import { endpoint, buildPayload } from './registry.js';
2
+ import { normalizeOrigin, resolveKey } from './config.js';
3
+ import { redactKey } from './http.js';
4
+
5
+ export class MeridianWebSocketError extends Error {
6
+ constructor(message, { status, requestId, body, key, cause } = {}) {
7
+ super(message, { cause });
8
+ this.name = 'MeridianWebSocketError';
9
+ this.status = status;
10
+ this.requestId = requestId;
11
+ this.body = body;
12
+ this.key = key;
13
+ }
14
+ toDiagnostic() {
15
+ return {
16
+ type: this.name, message: this.message, status: this.status,
17
+ request_id: redactKey(this.requestId, this.key), raw_body: redactKey(this.body, this.key),
18
+ cause: this.cause ? redactKey(String(this.cause), this.key) : undefined,
19
+ };
20
+ }
21
+ }
22
+
23
+ export async function streamNewsflash(values, {
24
+ key, websocketBaseUrl, count, idleTimeout,
25
+ onMessage = (line) => process.stdout.write(`${line}\n`),
26
+ socketFactory,
27
+ } = {}) {
28
+ const meta = endpoint('ws_newsflash', 'websocket');
29
+ if (!meta) throw new Error('ws_newsflash is absent from the registry');
30
+ const subscription = buildPayload(meta, values);
31
+ const apiKey = resolveKey(key);
32
+ const origin = websocketBaseUrl === undefined ? meta.origin : normalizeOrigin(websocketBaseUrl, ['ws:', 'wss:']);
33
+ const max = count === undefined ? Infinity : Number(count);
34
+ if (!(max > 0 && (max === Infinity || Number.isInteger(max)))) throw new Error('count must be a positive integer');
35
+ const idleMs = idleTimeout === undefined ? undefined : Number(idleTimeout);
36
+ if (idleMs !== undefined && (!Number.isFinite(idleMs) || idleMs <= 0)) throw new Error('idle-timeout must be positive milliseconds');
37
+ const factory = socketFactory ?? ((await import('ws')).default);
38
+ const socket = new factory(`${origin}${meta.path}`, { headers: { Authorization: `Bearer ${apiKey}` } });
39
+ return new Promise((resolve, reject) => {
40
+ let received = 0;
41
+ let timer;
42
+ let settled = false;
43
+ const finish = (error) => {
44
+ if (settled) return;
45
+ settled = true;
46
+ if (timer) clearTimeout(timer);
47
+ if (error) reject(error); else resolve({ received });
48
+ };
49
+ const resetTimer = () => {
50
+ if (timer) clearTimeout(timer);
51
+ if (idleMs) timer = setTimeout(() => socket.close(), idleMs);
52
+ };
53
+ socket.on('open', () => { socket.send(JSON.stringify(subscription)); resetTimer(); });
54
+ socket.on('message', (data) => {
55
+ resetTimer();
56
+ received++;
57
+ try { onMessage(String(data)); }
58
+ catch (cause) { finish(new MeridianWebSocketError('Could not handle WebSocket message', { cause, key: apiKey })); socket.close(); return; }
59
+ if (received >= max) socket.close();
60
+ });
61
+ socket.on('unexpected-response', (_request, response) => {
62
+ const chunks = [];
63
+ response.on('data', (chunk) => chunks.push(chunk));
64
+ response.on('end', () => finish(new MeridianWebSocketError('WebSocket handshake failed', {
65
+ status: response.statusCode, requestId: response.headers['x-meridian-request-id'],
66
+ body: Buffer.concat(chunks).toString('utf8'), key: apiKey,
67
+ })));
68
+ });
69
+ socket.on('error', (cause) => finish(new MeridianWebSocketError('WebSocket transport failed', { cause, key: apiKey })));
70
+ socket.on('close', () => finish());
71
+ });
72
+ }