@substrio/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.
- package/LICENSE +177 -0
- package/README.md +66 -2
- package/bin/substrio.mjs +91 -0
- package/package.json +44 -4
- package/src/api.mjs +107 -0
- package/src/apply.mjs +204 -0
- package/src/blobs.mjs +102 -0
- package/src/cli.mjs +405 -0
- package/src/collections.mjs +701 -0
- package/src/command-table.mjs +436 -0
- package/src/commands/bundle.mjs +369 -0
- package/src/commands/count.mjs +39 -0
- package/src/commands/deploy.mjs +488 -0
- package/src/commands/dev.mjs +246 -0
- package/src/commands/env.mjs +115 -0
- package/src/commands/files.mjs +172 -0
- package/src/commands/git.mjs +195 -0
- package/src/commands/groups-runtime.mjs +227 -0
- package/src/commands/groups.mjs +417 -0
- package/src/commands/history.mjs +374 -0
- package/src/commands/import.mjs +506 -0
- package/src/commands/init.mjs +175 -0
- package/src/commands/key.mjs +172 -0
- package/src/commands/login.mjs +167 -0
- package/src/commands/logs.mjs +285 -0
- package/src/commands/mcp-config.mjs +102 -0
- package/src/commands/op.mjs +265 -0
- package/src/commands/open.mjs +35 -0
- package/src/commands/push.mjs +199 -0
- package/src/commands/signup.mjs +164 -0
- package/src/commands/site.mjs +129 -0
- package/src/commands/skills.mjs +177 -0
- package/src/commands/status.mjs +78 -0
- package/src/commands/tenants.mjs +172 -0
- package/src/commands/test.mjs +167 -0
- package/src/commands/tools.mjs +63 -0
- package/src/commands/versions.mjs +293 -0
- package/src/config.mjs +569 -0
- package/src/documents.mjs +139 -0
- package/src/function-build.mjs +273 -0
- package/src/identity.mjs +248 -0
- package/src/jsonfile.mjs +22 -0
- package/src/live-move.mjs +267 -0
- package/src/local/args.mjs +58 -0
- package/src/local/reload.mjs +408 -0
- package/src/local/server.mjs +291 -0
- package/src/local/watch.mjs +175 -0
- package/src/manifest.mjs +591 -0
- package/src/ops.mjs +265 -0
- package/src/readers.mjs +416 -0
- package/src/sdk.mjs +26 -0
- package/src/settings.mjs +117 -0
- package/src/testing/app.mjs +507 -0
- package/src/testing/env.mjs +35 -0
- package/src/testing/index.mjs +11 -0
- package/src/testing/types.mjs +55 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: `substrio data import COLLECTION --file F`.
|
|
3
|
+
*
|
|
4
|
+
* substrio data import COLLECTION --file FILE [--format ndjson|json|csv]
|
|
5
|
+
* NDJSON (.ndjson, .jsonl), a JSON array, {"documents": [...]} or CSV
|
|
6
|
+
* (- is stdin; --format is needed for stdin, and overrides the
|
|
7
|
+
* extension for a file)
|
|
8
|
+
* substrio data import COLLECTION --data TEXT the same, given inline
|
|
9
|
+
*
|
|
10
|
+
* --types a:string,b:number,c:date,d:boolean,e:json CSV columns' types (default: string)
|
|
11
|
+
* --empty-as null an empty CSV field becomes null (default: the field is absent)
|
|
12
|
+
* --batch-bytes N, --batch-docs N a batch's size cap (bytes of JSON text, document count)
|
|
13
|
+
* --concurrency N batches in flight at once (default 4; 1 is today's order)
|
|
14
|
+
* --checkpoint FILE where progress is saved (default INPUT.substrio-import); only
|
|
15
|
+
* written with --upsert or --on-conflict skip (see --resume)
|
|
16
|
+
* --resume continue from the checkpoint (needs a file, not stdin, and
|
|
17
|
+
* --upsert or --on-conflict skip: without an _id, a batch the
|
|
18
|
+
* server stored just before a crash would be sent again)
|
|
19
|
+
* --upsert a document with _id already stored is replaced, not refused
|
|
20
|
+
* (data.import's own on_conflict=replace: one call per batch)
|
|
21
|
+
* --on-conflict skip a document whose _id is already stored is skipped, not refused
|
|
22
|
+
* (data.import's own on_conflict=skip: one call per batch)
|
|
23
|
+
* --allow-failures exit 0 even when some documents were refused
|
|
24
|
+
*
|
|
25
|
+
* The documents are read (src/documents.mjs, src/readers.mjs) a chunk at a
|
|
26
|
+
* time, as their own bytes, never parsed or re-serialized: a file of any
|
|
27
|
+
* size is never loaded whole, and no document pays a JSON.parse or
|
|
28
|
+
* JSON.stringify to pass through. They are cut into batches (by byte size
|
|
29
|
+
* and document count) and sent through the registry's data.import, as
|
|
30
|
+
* NDJSON or a JSON array (its own `format` query option) with `on_conflict`
|
|
31
|
+
* when --upsert or --on-conflict skip is given, several batches in flight at
|
|
32
|
+
* once; a 429 or 503 backs off and retries, never dropping a batch (each
|
|
33
|
+
* retry, and the wait it is taking, is reported as it happens); any other
|
|
34
|
+
* failure stops the run. A document is refused alone and the others are
|
|
35
|
+
* written: every refused one, a line, element or row that is not usable
|
|
36
|
+
* included, is reported with its 1-based position, its code and its reason
|
|
37
|
+
* (a schema or CSV-type refusal also each rule it broke, as `violations`),
|
|
38
|
+
* and the run exits non-zero with ExitCode.PARTIAL (8) unless
|
|
39
|
+
* --allow-failures, apart from every failure of the whole run, so a script
|
|
40
|
+
* can tell "some rows were refused, the rest stored" from a run that
|
|
41
|
+
* failed. A document skipped or replaced under --on-conflict is not a
|
|
42
|
+
* failure. A call that fails as a whole (the server refuses the batch, or is
|
|
43
|
+
* gone) stops the run: what was read up to there is reported, the documents
|
|
44
|
+
* after it were not read, and the run exits with the server's typed exit
|
|
45
|
+
* code.
|
|
46
|
+
*
|
|
47
|
+
* Under --json, one JSON line is written per progress tick (at most one a
|
|
48
|
+
* second), one per refused document and one per retry, as they happen, and
|
|
49
|
+
* exactly one final line (`done` or `stopped`): nothing else reaches stdout.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { chmodSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
53
|
+
|
|
54
|
+
import { CliError, ErrorCode, ExitCode, emitLine, fail, globals } from '../cli.mjs';
|
|
55
|
+
import { errorText } from '../api.mjs';
|
|
56
|
+
import { DocumentForm, formOfFile, readDocuments } from '../documents.mjs';
|
|
57
|
+
import { findOperation } from '../ops.mjs';
|
|
58
|
+
import { CsvType } from '../readers.mjs';
|
|
59
|
+
import { FlagKind, parseFlags, wholeNumber } from '../local/args.mjs';
|
|
60
|
+
import { OpError, callOperation, loadOperations } from './op.mjs';
|
|
61
|
+
|
|
62
|
+
const C = {
|
|
63
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[91m', green: '\x1b[92m', yellow: '\x1b[93m',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** The documents of one call: enough for a request of ordinary size, the common case. */
|
|
67
|
+
export const BATCH_DOCUMENTS = 1000;
|
|
68
|
+
/**
|
|
69
|
+
* The JSON text of one call, reached before its document count: large
|
|
70
|
+
* documents go in fewer per call. A single larger document goes alone. Kept
|
|
71
|
+
* below the server's own request-body ceiling (substrio_server.h:63, 16 MiB).
|
|
72
|
+
*/
|
|
73
|
+
export const BATCH_BYTES = 4 * 1024 * 1024;
|
|
74
|
+
/** Batches sent at once by default; 1 restores the strict, in-order behaviour of a single connection. */
|
|
75
|
+
export const CONCURRENCY = 4;
|
|
76
|
+
/** The first retry's delay (SUBSTRIO_IMPORT_RETRY_MS sets another); it doubles up to RETRY_MAX_MS, each with up to 50% jitter. */
|
|
77
|
+
export const RETRY_BASE_MS = wholeNumberOr(process.env.SUBSTRIO_IMPORT_RETRY_MS, 100);
|
|
78
|
+
export const RETRY_MAX_MS = 10_000;
|
|
79
|
+
|
|
80
|
+
const USAGE = 'substrio data import COLLECTION --file FILE [--format ndjson|json|csv] (- is stdin) | --data TEXT';
|
|
81
|
+
|
|
82
|
+
export const IMPORT_FLAGS = Object.freeze({
|
|
83
|
+
'--file': FlagKind.VALUE, '--data': FlagKind.VALUE, '--format': FlagKind.VALUE,
|
|
84
|
+
'--types': FlagKind.VALUE, '--empty-as': FlagKind.VALUE,
|
|
85
|
+
'--batch-bytes': FlagKind.VALUE, '--batch-docs': FlagKind.VALUE, '--concurrency': FlagKind.VALUE,
|
|
86
|
+
'--checkpoint': FlagKind.VALUE, '--resume': FlagKind.SWITCH,
|
|
87
|
+
'--upsert': FlagKind.SWITCH, '--on-conflict': FlagKind.VALUE, '--allow-failures': FlagKind.SWITCH,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** The codes this CLI gives a document it refused itself. */
|
|
91
|
+
export const RejectCode = Object.freeze({
|
|
92
|
+
INVALID_DOCUMENT: 'INVALID_DOCUMENT', // a line, element or row that is not usable
|
|
93
|
+
UNACCOUNTED: 'UNACCOUNTED', // the server answered for fewer documents than the batch held
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
97
|
+
|
|
98
|
+
// The wire-batch separators (Buffer.from once, never re-allocated per document or per batch).
|
|
99
|
+
const NEWLINE = Buffer.from('\n');
|
|
100
|
+
const COMMA = Buffer.from(',');
|
|
101
|
+
const OPEN_BRACKET = Buffer.from('[');
|
|
102
|
+
const CLOSE_BRACKET = Buffer.from(']');
|
|
103
|
+
|
|
104
|
+
function wholeNumberOr(text, fallback) {
|
|
105
|
+
const n = Number(text);
|
|
106
|
+
return text !== undefined && text !== '' && Number.isInteger(n) && n >= 0 ? n : fallback;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A delay for the (attempt)th retry of a 429/503: doubling from RETRY_BASE_MS, capped at RETRY_MAX_MS, with jitter.
|
|
110
|
+
function backoffMs(attempt) {
|
|
111
|
+
const base = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** (attempt - 1));
|
|
112
|
+
return base * (0.5 + Math.random() * 0.5);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// An answer that does not say whether the batch was stored: no answer at all (the connection
|
|
116
|
+
// dropped), or a gateway between the CLI and the server that gave up on it (502, 504, and a
|
|
117
|
+
// proxy's own 520 to 524, such as a CDN in front of the server).
|
|
118
|
+
const UNKNOWN_FATE = new Set([502, 504, 520, 521, 522, 523, 524]);
|
|
119
|
+
/** Sends of one batch whose fate stays unknown before the import stops (a server that is gone stays gone). */
|
|
120
|
+
export const RETRY_UNKNOWN_ATTEMPTS = 8;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Sends `items` through `send(items, again)`, retrying a 429 or 503 answer with backoff
|
|
124
|
+
* (never giving up: the batch is never dropped), the server's own
|
|
125
|
+
* Retry-After wait (`result.retryAfterMs`, callOperation's) when it sent
|
|
126
|
+
* one, else `backoffMs`. A batch whose fate is unknown (UNKNOWN_FATE, or a
|
|
127
|
+
* NETWORK_ERROR thrown by `send`) is sent again up to RETRY_UNKNOWN_ATTEMPTS
|
|
128
|
+
* times, with `again` true from then on: the caller makes a plain insert
|
|
129
|
+
* on_conflict skip for it, so what landed the first time is skipped, not
|
|
130
|
+
* refused (the stored count, not the report, is what a caller checks).
|
|
131
|
+
* `onRetry({status, attempt, wait_ms})` fires before each wait (status 0:
|
|
132
|
+
* no answer), so a retry is never silent.
|
|
133
|
+
*/
|
|
134
|
+
async function sendWithRetry(items, send, onRetry) {
|
|
135
|
+
let again = false;
|
|
136
|
+
for (let attempt = 1; ; attempt++) {
|
|
137
|
+
let result;
|
|
138
|
+
try {
|
|
139
|
+
result = await send(items, again);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (!(err instanceof CliError) || err.code !== ErrorCode.NETWORK_ERROR || attempt >= RETRY_UNKNOWN_ATTEMPTS) throw err;
|
|
142
|
+
result = { ok: false, status: 0 };
|
|
143
|
+
}
|
|
144
|
+
if (result.ok) return result;
|
|
145
|
+
const unknown = result.status === 0 || UNKNOWN_FATE.has(result.status);
|
|
146
|
+
if (unknown ? attempt >= RETRY_UNKNOWN_ATTEMPTS : (result.status !== 429 && result.status !== 503)) return result;
|
|
147
|
+
again ||= unknown;
|
|
148
|
+
const waitMs = result.retryAfterMs ?? backoffMs(attempt);
|
|
149
|
+
onRetry?.({ status: result.status, attempt, wait_ms: Math.round(waitMs) });
|
|
150
|
+
await sleep(waitMs);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Sends `documents` (an async iterable of {position, raw, endOffset} or
|
|
156
|
+
* {position, error, endOffset, violations?}; `raw` is a Buffer of that
|
|
157
|
+
* document's own JSON text) in batches through `send(items)` (the items of
|
|
158
|
+
* the batch, callOperation's {ok, status, data, request, retryAfterMs}
|
|
159
|
+
* shape back), up to `concurrency` at once. Returns the report: {total,
|
|
160
|
+
* inserted, skipped, replaced, rejectedCount, rejected?, stopped: null |
|
|
161
|
+
* {position, error}}. `rejected` ({position, code, message, violations?})
|
|
162
|
+
* is kept only when `onReject` is not given (a bounded caller, library use
|
|
163
|
+
* or a test): with it, every rejection is handed to `onReject(entry, report)`
|
|
164
|
+
* the moment it is known (read time or batch time) and none are kept, so a
|
|
165
|
+
* run with no bound on failures does not grow one unbounded array. `onBatch
|
|
166
|
+
* (endOffset)` fires, in input order, once every batch up to and including
|
|
167
|
+
* it is done and written (a batch the server refused as a whole never fires
|
|
168
|
+
* it, so the checkpoint never passes data that was not stored). `onRetry`
|
|
169
|
+
* is sendWithRetry's.
|
|
170
|
+
*/
|
|
171
|
+
export async function importDocuments(documents, send, {
|
|
172
|
+
batchDocuments = BATCH_DOCUMENTS, batchBytes = BATCH_BYTES, concurrency = 1, onReject = null, onBatch = null, onRetry = null,
|
|
173
|
+
} = {}) {
|
|
174
|
+
const report = { total: 0, inserted: 0, skipped: 0, replaced: 0, rejectedCount: 0, stopped: null };
|
|
175
|
+
if (onReject === null) report.rejected = [];
|
|
176
|
+
const reject = (entry) => {
|
|
177
|
+
report.rejectedCount++;
|
|
178
|
+
if (onReject === null) report.rejected.push(entry);
|
|
179
|
+
else onReject(entry, report);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
let batch = [];
|
|
183
|
+
let bytes = 0;
|
|
184
|
+
let seq = 0;
|
|
185
|
+
let nextSeq = 0;
|
|
186
|
+
const finished = new Map(); // seq -> {endOffset, ok}
|
|
187
|
+
const inFlight = new Set();
|
|
188
|
+
let fatal = null;
|
|
189
|
+
|
|
190
|
+
const advance = () => {
|
|
191
|
+
while (finished.has(nextSeq)) {
|
|
192
|
+
const entry = finished.get(nextSeq);
|
|
193
|
+
finished.delete(nextSeq);
|
|
194
|
+
nextSeq++;
|
|
195
|
+
if (!entry.ok) break; // a batch the server refused as a whole: nothing past it is acknowledged
|
|
196
|
+
onBatch?.(entry.endOffset, report);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const runBatch = async (mySeq, items) => {
|
|
201
|
+
const result = await sendWithRetry(items, send, onRetry);
|
|
202
|
+
if (fatal === null) {
|
|
203
|
+
if (!result.ok) {
|
|
204
|
+
fatal = { position: items[0].position, error: CliError.fromResponse(`${result.request.method} ${result.request.path}: ${errorText(result.data, result.status)}`, result.data, result.status) };
|
|
205
|
+
} else {
|
|
206
|
+
const { inserted = 0, skipped = 0, replaced = 0, errors = [] } = result.data ?? {};
|
|
207
|
+
report.inserted += inserted;
|
|
208
|
+
report.skipped += skipped;
|
|
209
|
+
report.replaced += replaced;
|
|
210
|
+
for (const e of errors) {
|
|
211
|
+
const refused = { position: items[e.index]?.position ?? null, code: e.code, message: e.message };
|
|
212
|
+
if (Array.isArray(e.violations)) refused.violations = e.violations;
|
|
213
|
+
reject(refused);
|
|
214
|
+
}
|
|
215
|
+
if (inserted + skipped + replaced + errors.length !== items.length) {
|
|
216
|
+
reject({
|
|
217
|
+
position: items[0].position, code: RejectCode.UNACCOUNTED,
|
|
218
|
+
message: `the server answered for ${inserted + skipped + replaced + errors.length} of the ${items.length} documents of the batch starting here`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
finished.set(mySeq, { endOffset: items[items.length - 1].endOffset, ok: result.ok });
|
|
224
|
+
advance();
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const dispatch = (items) => {
|
|
228
|
+
const mySeq = seq++;
|
|
229
|
+
const p = runBatch(mySeq, items).finally(() => inFlight.delete(p));
|
|
230
|
+
inFlight.add(p);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
let unread = null;
|
|
234
|
+
const iterator = documents[Symbol.asyncIterator]();
|
|
235
|
+
try {
|
|
236
|
+
while (fatal === null) {
|
|
237
|
+
const { value: item, done } = await iterator.next();
|
|
238
|
+
if (done) break;
|
|
239
|
+
report.total++;
|
|
240
|
+
if (item.error !== undefined) {
|
|
241
|
+
reject({ position: item.position, code: RejectCode.INVALID_DOCUMENT, message: item.error, ...(item.violations ? { violations: item.violations } : {}) });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
batch.push(item);
|
|
245
|
+
bytes += item.raw.length;
|
|
246
|
+
if (batch.length >= batchDocuments || bytes >= batchBytes) {
|
|
247
|
+
dispatch(batch);
|
|
248
|
+
batch = [];
|
|
249
|
+
bytes = 0;
|
|
250
|
+
if (inFlight.size >= concurrency) await Promise.race(inFlight);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch (err) {
|
|
254
|
+
if (!(err instanceof CliError)) throw err;
|
|
255
|
+
unread = { position: report.total + 1, error: err };
|
|
256
|
+
}
|
|
257
|
+
if (fatal === null && batch.length) dispatch(batch);
|
|
258
|
+
await Promise.all(inFlight);
|
|
259
|
+
report.stopped = fatal ?? unread;
|
|
260
|
+
return report;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// One rejection, as the lines printed for it (human mode): the moment it is known (onReject),
|
|
264
|
+
// never kept to be listed at the end (report.rejectedCount is the bounded count that is kept).
|
|
265
|
+
function violationLines(entry) {
|
|
266
|
+
const lines = [` ${C.red}✗${C.reset} document ${entry.position ?? '?'}: ${entry.code ? `${entry.code}: ` : ''}${entry.message}`];
|
|
267
|
+
for (const v of entry.violations ?? []) lines.push(` ${C.dim}${v.field}: ${v.message}${C.reset}`);
|
|
268
|
+
return lines;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// The summary line and, when the run stopped, why: never a list of rejections (streamed live instead, as
|
|
272
|
+
// they happen, by onReject; report.rejectedCount is the count, not kept as an array under --json either).
|
|
273
|
+
function describe(report, collection) {
|
|
274
|
+
const lines = [];
|
|
275
|
+
const ok = report.rejectedCount === 0 && report.stopped === null;
|
|
276
|
+
const extras = [
|
|
277
|
+
report.rejectedCount ? `${report.rejectedCount} rejected` : '',
|
|
278
|
+
report.skipped ? `${report.skipped} skipped` : '',
|
|
279
|
+
report.replaced ? `${report.replaced} replaced` : '',
|
|
280
|
+
].filter(Boolean).join(', ');
|
|
281
|
+
lines.push(`${ok ? C.green : C.yellow}${ok ? '✓' : '⚠'}${C.reset} Imported ${C.bold}${report.inserted}${C.reset} of ${report.total} documents into ${C.bold}${collection}${C.reset}` +
|
|
282
|
+
`${extras ? `; ${extras}` : ''}`);
|
|
283
|
+
if (report.stopped !== null) {
|
|
284
|
+
lines.push(` ${C.red}✗${C.reset} stopped at document ${report.stopped.position}: ${report.stopped.error.message}`);
|
|
285
|
+
lines.push(` ${C.dim}document ${report.stopped.position} and everything after it were not imported${C.reset}`);
|
|
286
|
+
}
|
|
287
|
+
return lines;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --types a:string,b:number,...: a Map field -> CsvType, or a typed usage error.
|
|
291
|
+
function parseTypes(text) {
|
|
292
|
+
const allowed = new Set(Object.values(CsvType));
|
|
293
|
+
const map = new Map();
|
|
294
|
+
for (const pair of text.split(',')) {
|
|
295
|
+
const at = pair.indexOf(':');
|
|
296
|
+
if (at <= 0) throw new CliError(`--types: "${pair}" must be field:type`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
297
|
+
const type = pair.slice(at + 1).trim();
|
|
298
|
+
if (!allowed.has(type)) throw new CliError(`--types: "${type}" is not a type (string, number, date, boolean or json)`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
299
|
+
map.set(pair.slice(0, at).trim(), type);
|
|
300
|
+
}
|
|
301
|
+
return map;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// The checkpoint file beside `path` (unless --checkpoint names one): never for stdin unless given explicitly.
|
|
305
|
+
function defaultCheckpointPath(path) {
|
|
306
|
+
return path === '-' ? null : `${path}.substrio-import`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// What must stay the same between a run and the --resume of it.
|
|
310
|
+
function checkpointOptionsOf({ format, typesText, upsert, onConflict, collection }) {
|
|
311
|
+
return { format, types: typesText ?? null, upsert, onConflict, collection };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function readCheckpoint(path) {
|
|
315
|
+
try {
|
|
316
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
317
|
+
} catch (err) {
|
|
318
|
+
throw new CliError(`--resume: ${path}: ${err.code === 'ENOENT' ? 'no checkpoint there' : err.message}`, { code: ErrorCode.FILE_NOT_FOUND });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Written to a temp file beside `path` then renamed, so it is never torn; owner-only (0600).
|
|
323
|
+
function writeCheckpoint(path, data) {
|
|
324
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
325
|
+
writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
326
|
+
chmodSync(tmp, 0o600);
|
|
327
|
+
renameSync(tmp, path);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** `substrio data import`: `resolved` is resolveGroup's {positional, rest}. */
|
|
331
|
+
export async function cmdImport(api, { positional, rest }) {
|
|
332
|
+
let flags;
|
|
333
|
+
let words;
|
|
334
|
+
try {
|
|
335
|
+
({ flags, positional: words } = parseFlags(rest, IMPORT_FLAGS));
|
|
336
|
+
} catch (err) {
|
|
337
|
+
return fail(err, USAGE);
|
|
338
|
+
}
|
|
339
|
+
const [collection, ...extra] = positional;
|
|
340
|
+
if (collection === undefined || extra.length || words.length) fail('data import takes one collection and --file or --data', USAGE);
|
|
341
|
+
if (flags.has('--file') === flags.has('--data')) fail('give the documents as --file FILE or --data TEXT, one of them', USAGE);
|
|
342
|
+
const source = flags.has('--file') ? { path: flags.get('--file') } : { text: flags.get('--data') };
|
|
343
|
+
const hasFile = source.path !== undefined; // --data (inline text) has no file: not seekable, no default checkpoint
|
|
344
|
+
const isStdin = source.path === '-';
|
|
345
|
+
|
|
346
|
+
let format;
|
|
347
|
+
try {
|
|
348
|
+
if (flags.has('--format')) {
|
|
349
|
+
const word = flags.get('--format');
|
|
350
|
+
if (!Object.values(DocumentForm).includes(word) || word === DocumentForm.AUTO) {
|
|
351
|
+
throw new CliError(`--format must be ndjson, json or csv`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
352
|
+
}
|
|
353
|
+
format = word;
|
|
354
|
+
} else if (hasFile && !isStdin) {
|
|
355
|
+
format = formOfFile(source.path) === DocumentForm.AUTO ? undefined : formOfFile(source.path);
|
|
356
|
+
}
|
|
357
|
+
const typesText = flags.get('--types');
|
|
358
|
+
const types = typesText !== undefined ? parseTypes(typesText) : null;
|
|
359
|
+
const emptyAsText = flags.get('--empty-as');
|
|
360
|
+
if (emptyAsText !== undefined && emptyAsText !== 'null') throw new CliError('--empty-as takes "null" (the only value it accepts today)', { code: ErrorCode.INVALID_ARGUMENT });
|
|
361
|
+
const emptyAs = emptyAsText === 'null' ? null : undefined;
|
|
362
|
+
|
|
363
|
+
const batchDocuments = wholeNumber(flags, '--batch-docs', 1, 1_000_000, BATCH_DOCUMENTS);
|
|
364
|
+
const batchBytes = wholeNumber(flags, '--batch-bytes', 1, 16 * 1024 * 1024, BATCH_BYTES);
|
|
365
|
+
const concurrency = wholeNumber(flags, '--concurrency', 1, 64, CONCURRENCY);
|
|
366
|
+
const upsert = flags.has('--upsert');
|
|
367
|
+
const onConflict = flags.get('--on-conflict') ?? null;
|
|
368
|
+
if (onConflict !== null && onConflict !== 'skip') throw new CliError('--on-conflict takes "skip" (the only value it accepts today)', { code: ErrorCode.INVALID_ARGUMENT });
|
|
369
|
+
const resume = flags.has('--resume');
|
|
370
|
+
const allowFailures = flags.has('--allow-failures');
|
|
371
|
+
|
|
372
|
+
// A batch the server stored just before a crash, and before the checkpoint past it was
|
|
373
|
+
// written, is sent again on --resume: only an id (--upsert or --on-conflict skip) makes
|
|
374
|
+
// that harmless, so without one no checkpoint is written at all, and --resume is refused.
|
|
375
|
+
const canResume = upsert || onConflict === 'skip';
|
|
376
|
+
if (resume && !hasFile) throw new CliError('--resume needs a file (--file FILE), not inline --data or stdin: it cannot be sought back to', { code: ErrorCode.INVALID_ARGUMENT });
|
|
377
|
+
if (resume && isStdin) throw new CliError('--resume needs a file (--file), not stdin: it cannot be sought back to', { code: ErrorCode.INVALID_ARGUMENT });
|
|
378
|
+
if (resume && !canResume) {
|
|
379
|
+
throw new CliError('--resume needs --upsert or --on-conflict skip: a batch the server stored just before a crash is sent again, and only an id makes that harmless', { code: ErrorCode.INVALID_ARGUMENT });
|
|
380
|
+
}
|
|
381
|
+
const checkpointPath = canResume ? (flags.get('--checkpoint') ?? (hasFile && !isStdin ? defaultCheckpointPath(source.path) : null)) : null;
|
|
382
|
+
|
|
383
|
+
let resumeOffset = 0;
|
|
384
|
+
let stat = null;
|
|
385
|
+
if (hasFile && !isStdin) {
|
|
386
|
+
try { stat = statSync(source.path); } catch (err) { throw new CliError(`${source.path}: ${err.message}`, { code: ErrorCode.FILE_NOT_FOUND }); }
|
|
387
|
+
}
|
|
388
|
+
if (resume) {
|
|
389
|
+
if (checkpointPath === null) throw new CliError('--resume needs --checkpoint (or a file input, for its default)', { code: ErrorCode.INVALID_ARGUMENT });
|
|
390
|
+
const saved = readCheckpoint(checkpointPath);
|
|
391
|
+
if (saved.input.size !== stat.size || saved.input.mtimeMs !== stat.mtimeMs) {
|
|
392
|
+
throw new CliError(`${source.path} changed since the checkpoint was made (size or modified time differs): --resume refused`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
393
|
+
}
|
|
394
|
+
const wanted = checkpointOptionsOf({ format: format ?? null, typesText, upsert, onConflict, collection });
|
|
395
|
+
if (JSON.stringify(wanted) !== JSON.stringify(saved.options)) {
|
|
396
|
+
throw new CliError('--resume: --format, --types, --upsert or --on-conflict differs from the checkpoint; run with the same options as before', { code: ErrorCode.INVALID_ARGUMENT });
|
|
397
|
+
}
|
|
398
|
+
resumeOffset = saved.offset;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let ops;
|
|
402
|
+
try {
|
|
403
|
+
ops = await loadOperations(api);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
return fail(err);
|
|
406
|
+
}
|
|
407
|
+
let routes;
|
|
408
|
+
try {
|
|
409
|
+
routes = findOperation(ops, 'data.import');
|
|
410
|
+
} catch (err) {
|
|
411
|
+
return fail(err);
|
|
412
|
+
}
|
|
413
|
+
const supportsQuery = (name) => routes.some((r) => r.query.includes(name));
|
|
414
|
+
if (!supportsQuery('format')) {
|
|
415
|
+
return fail(new CliError("this server's data.import has no format option (needed to send the input as its own bytes, unparsed): the CLI needs a newer server", { code: ErrorCode.INVALID_ARGUMENT }));
|
|
416
|
+
}
|
|
417
|
+
const conflictMode = upsert ? 'replace' : (onConflict === 'skip' ? 'skip' : null);
|
|
418
|
+
if (conflictMode !== null && !supportsQuery('on_conflict')) {
|
|
419
|
+
return fail(new CliError(`--${upsert ? 'upsert' : 'on-conflict skip'}: this server's data.import has no on_conflict option`, { code: ErrorCode.INVALID_ARGUMENT }));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// The wire format: a JSON array (bracket/comma-joined) only for --format json (the
|
|
423
|
+
// jsonArrayDocuments reader); every other source (NDJSON, CSV, the AUTO fallback) hands
|
|
424
|
+
// each document its own JSON text already, newline-joined, and the server reads it the
|
|
425
|
+
// same way NDJSON always is. One Buffer.concat per batch, never per document.
|
|
426
|
+
const wireFormat = format === DocumentForm.JSON ? 'json' : 'ndjson';
|
|
427
|
+
const contentType = wireFormat === 'json' ? 'application/json' : 'application/x-ndjson';
|
|
428
|
+
const joinBatch = wireFormat === 'json'
|
|
429
|
+
? (items) => { const parts = [OPEN_BRACKET]; for (let k = 0; k < items.length; k++) { if (k > 0) parts.push(COMMA); parts.push(items[k].raw); } parts.push(CLOSE_BRACKET); return Buffer.concat(parts); }
|
|
430
|
+
: (items) => { const parts = []; for (let k = 0; k < items.length; k++) { if (k > 0) parts.push(NEWLINE); parts.push(items[k].raw); } return Buffer.concat(parts); };
|
|
431
|
+
// `again`: a resend of a batch whose fate was unknown (sendWithRetry); a plain insert
|
|
432
|
+
// skips what landed the first time instead of refusing it.
|
|
433
|
+
const canSkip = supportsQuery('on_conflict');
|
|
434
|
+
const send = async (items, again = false) => {
|
|
435
|
+
const body = joinBatch(items);
|
|
436
|
+
const mode = conflictMode ?? (again && canSkip ? 'skip' : null);
|
|
437
|
+
try {
|
|
438
|
+
return await callOperation(api, ops, 'data.import', {
|
|
439
|
+
collection, format: wireFormat, ...(mode !== null ? { on_conflict: mode } : {}),
|
|
440
|
+
}, null, { body, headers: { 'Content-Type': contentType } });
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (err instanceof OpError) throw new CliError(err.message, { code: ErrorCode.INVALID_ARGUMENT });
|
|
443
|
+
throw err;
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const totalBytes = stat?.size ?? null;
|
|
448
|
+
let lastTick = 0;
|
|
449
|
+
const tick = (live, extra2 = {}) => {
|
|
450
|
+
const now = Date.now();
|
|
451
|
+
if (now - lastTick < 1000) return;
|
|
452
|
+
lastTick = now;
|
|
453
|
+
const elapsedS = Math.max(0.001, (now - started) / 1000);
|
|
454
|
+
emitLine({
|
|
455
|
+
event: 'progress', imported: live.inserted, skipped: live.skipped, replaced: live.replaced, failed: live.rejectedCount,
|
|
456
|
+
offset: lastOffset, total_bytes: totalBytes, docs_per_s: Math.round((live.inserted / elapsedS) * 10) / 10, ...extra2,
|
|
457
|
+
}, () => { if (process.stdout.isTTY) process.stdout.write(`\rImported ${live.inserted}, ${live.rejectedCount} failed${totalBytes ? ` (${Math.round((lastOffset / totalBytes) * 100)}%)` : ''}...`); });
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const started = Date.now();
|
|
461
|
+
let lastOffset = resumeOffset;
|
|
462
|
+
let report;
|
|
463
|
+
const nextCommand = () => `substrio data import ${collection} --file ${source.path} --checkpoint ${checkpointPath} --resume${upsert ? ' --upsert' : ''}${onConflict ? ` --on-conflict ${onConflict}` : ''}`;
|
|
464
|
+
try {
|
|
465
|
+
report = await importDocuments(readDocuments(source, { form: format, types, emptyAs, resumeOffset }), send, {
|
|
466
|
+
batchDocuments, batchBytes, concurrency,
|
|
467
|
+
onReject: (entry, live) => { emitLine({ event: 'violation', ...entry }, () => { for (const line of violationLines(entry)) console.log(line); }); tick(live); },
|
|
468
|
+
onRetry: ({ status, attempt, wait_ms }) => {
|
|
469
|
+
emitLine({ event: 'retry', status, attempt, wait_ms }, () => console.log(` ${C.dim}retry ${attempt}: HTTP ${status}, waiting ${wait_ms}ms${C.reset}`));
|
|
470
|
+
},
|
|
471
|
+
onBatch: (endOffset, live) => {
|
|
472
|
+
lastOffset = endOffset;
|
|
473
|
+
if (checkpointPath !== null) {
|
|
474
|
+
writeCheckpoint(checkpointPath, {
|
|
475
|
+
offset: endOffset, imported: live.inserted, skipped: live.skipped, replaced: live.replaced, failed: live.rejectedCount,
|
|
476
|
+
input: { size: stat.size, mtimeMs: stat.mtimeMs }, collection,
|
|
477
|
+
options: checkpointOptionsOf({ format: format ?? null, typesText, upsert, onConflict, collection }),
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
tick(live);
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
} catch (err) {
|
|
484
|
+
return fail(err);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const elapsedMs = Date.now() - started;
|
|
488
|
+
if (report.stopped !== null) {
|
|
489
|
+
if (globals.json) {
|
|
490
|
+
emitLine({ event: 'stopped', error: { ...report.stopped.error.toJSON(), next: nextCommand() } }, () => {});
|
|
491
|
+
} else {
|
|
492
|
+
for (const line of describe(report, collection)) console.log(line);
|
|
493
|
+
}
|
|
494
|
+
return fail(report.stopped.error);
|
|
495
|
+
}
|
|
496
|
+
if (globals.json) {
|
|
497
|
+
emitLine({ event: 'done', imported: report.inserted, skipped: report.skipped, replaced: report.replaced, failed: report.rejectedCount, elapsed_ms: elapsedMs, checkpoint: checkpointPath }, () => {});
|
|
498
|
+
} else {
|
|
499
|
+
if (process.stdout.isTTY) process.stdout.write('\r\n');
|
|
500
|
+
for (const line of describe(report, collection)) console.log(line);
|
|
501
|
+
}
|
|
502
|
+
if (report.rejectedCount > 0 && !allowFailures) process.exitCode = ExitCode.PARTIAL;
|
|
503
|
+
} catch (err) {
|
|
504
|
+
return fail(err);
|
|
505
|
+
}
|
|
506
|
+
}
|