@substrio/cli 0.0.0-stage → 0.1.0
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
package/src/ops.mjs
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: any registry operation, by name, through its HTTP route.
|
|
3
|
+
*
|
|
4
|
+
* The server describes every operation of its registry, with its route, in
|
|
5
|
+
* GET /v1/_openapi (x-substrio-operation is the dotted name, such as webhook.get);
|
|
6
|
+
* nothing here knows one operation from another. An input is built from
|
|
7
|
+
* --data, --file and key=value arguments, the route's {placeholders} are
|
|
8
|
+
* filled from it, declared query parameters go in the query string, and what
|
|
9
|
+
* is left goes in the query string (GET, HEAD, DELETE) or the JSON body.
|
|
10
|
+
*
|
|
11
|
+
* Reading a catalog route, picking the route and building the request are the
|
|
12
|
+
* SDK's operation client's (one owner); this module adds the CLI's input
|
|
13
|
+
* parsing, name lookup and listing. The client loads through ./sdk.mjs
|
|
14
|
+
* (the package when installed, the sibling packages/substrio-sdk in a checkout).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { catalogRoute, pickRoute as pickOperationRoute } from './sdk.mjs';
|
|
19
|
+
|
|
20
|
+
export { buildRequest } from './sdk.mjs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A key=value argument's value: JSON when it parses as JSON (numbers, true,
|
|
24
|
+
* false, null, arrays, objects, quoted strings), else the text itself.
|
|
25
|
+
* `key:=value` forces JSON and `key==value` forces text.
|
|
26
|
+
*/
|
|
27
|
+
function argValue(raw, mode) {
|
|
28
|
+
if (mode === 'text') return raw;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(raw);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (mode === 'json') throw new Error(`not valid JSON: ${raw} (${err.message})`);
|
|
33
|
+
return raw;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Sets a dotted key (a.b.c) in obj, making tables along the way.
|
|
38
|
+
function setPath(obj, key, value) {
|
|
39
|
+
const parts = key.split('.');
|
|
40
|
+
let at = obj;
|
|
41
|
+
for (const part of parts.slice(0, -1)) {
|
|
42
|
+
if (at[part] === undefined) at[part] = {};
|
|
43
|
+
else if (typeof at[part] !== 'object' || at[part] === null || Array.isArray(at[part])) {
|
|
44
|
+
throw new Error(`${key}: ${part} is already a value, not a table`);
|
|
45
|
+
}
|
|
46
|
+
at = at[part];
|
|
47
|
+
}
|
|
48
|
+
at[parts[parts.length - 1]] = value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The input an invocation gives, from its arguments (pure):
|
|
53
|
+
* --data '{...}' an object (or any JSON value for a body-only op)
|
|
54
|
+
* --file f.json the same, from a file ("-" reads stdin)
|
|
55
|
+
* key=value one field, dotted keys nest; later ones win
|
|
56
|
+
* --method M which route, when the operation has several methods
|
|
57
|
+
* --page a paged list: this one page, not every page
|
|
58
|
+
* Returns {input, flags} where flags holds raw, method and page. --help, --json
|
|
59
|
+
* (output) and the other global flags are taken off by the one global parser
|
|
60
|
+
* (cli.mjs parseGlobal) before this runs.
|
|
61
|
+
*/
|
|
62
|
+
export function parseOpArgs(args, readFile = (f) => readFileSync(f === '-' ? 0 : f, 'utf8')) {
|
|
63
|
+
let input;
|
|
64
|
+
const fields = {};
|
|
65
|
+
const flags = { raw: false, method: null, page: false };
|
|
66
|
+
for (let i = 0; i < args.length; i++) {
|
|
67
|
+
const arg = args[i];
|
|
68
|
+
if (arg === '--raw') flags.raw = true;
|
|
69
|
+
else if (arg === '--page') flags.page = true;
|
|
70
|
+
else if (arg === '--method' || arg === '-X') {
|
|
71
|
+
if (i + 1 >= args.length) throw new Error(`${arg} needs a value`);
|
|
72
|
+
flags.method = args[++i].toUpperCase();
|
|
73
|
+
}
|
|
74
|
+
else if (arg === '--data' || arg === '--file') {
|
|
75
|
+
if (i + 1 >= args.length) throw new Error(`${arg} needs a value`);
|
|
76
|
+
const text = arg === '--data' ? args[++i] : readFile(args[++i]);
|
|
77
|
+
try {
|
|
78
|
+
input = JSON.parse(text);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
throw new Error(`${arg}: not valid JSON (${err.message})`);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
const m = arg.match(/^([^=:]+?)(:=|==|=)([\s\S]*)$/);
|
|
84
|
+
if (!m) throw new Error(`unexpected argument "${arg}" (use key=value, --data or --file)`);
|
|
85
|
+
const mode = m[2] === ':=' ? 'json' : m[2] === '==' ? 'text' : 'auto';
|
|
86
|
+
setPath(fields, m[1], argValue(m[3], mode));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (Object.keys(fields).length > 0) {
|
|
90
|
+
if (input !== undefined && (typeof input !== 'object' || input === null || Array.isArray(input))) {
|
|
91
|
+
throw new Error('key=value fields need --data or --file to be an object');
|
|
92
|
+
}
|
|
93
|
+
input = { ...(input || {}), ...fields };
|
|
94
|
+
}
|
|
95
|
+
return { input, flags };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Flags that take the next argument as their value.
|
|
99
|
+
const VALUE_FLAGS = new Set(['--data', '--file', '--method', '-X']);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A wrapper's arguments split in two (pure): `positional` (bare words and
|
|
103
|
+
* "-", which fill the route's path parameters in order) and `rest` (key=value fields
|
|
104
|
+
* and flags, for parseOpArgs). `valueFlags` are the wrapper's own flags that take the next
|
|
105
|
+
* argument as their value, besides those of parseOpArgs. `--` ends option
|
|
106
|
+
* parsing: every argument after it is positional, whatever it starts with
|
|
107
|
+
* (`substrio undo -- -abc`).
|
|
108
|
+
*/
|
|
109
|
+
export function splitPositional(args, valueFlags = []) {
|
|
110
|
+
const positional = [];
|
|
111
|
+
const rest = [];
|
|
112
|
+
for (let i = 0; i < args.length; i++) {
|
|
113
|
+
const arg = args[i];
|
|
114
|
+
if (arg === '--') {
|
|
115
|
+
positional.push(...args.slice(i + 1));
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
if (VALUE_FLAGS.has(arg) || valueFlags.includes(arg)) {
|
|
119
|
+
rest.push(arg);
|
|
120
|
+
if (i + 1 < args.length) rest.push(args[++i]);
|
|
121
|
+
} else if ((arg.startsWith('-') && arg !== '-') || /^[^=:]+?(:=|==|=)/.test(arg)) rest.push(arg);
|
|
122
|
+
else positional.push(arg);
|
|
123
|
+
}
|
|
124
|
+
return { positional, rest };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The operations an OpenAPI document (GET /v1/_openapi) describes, one per
|
|
129
|
+
* route (pure): the SDK's catalog route (method, path, params, query,
|
|
130
|
+
* parameters, body, bodyProperty, bodySchema) with {name, id, summary, tags},
|
|
131
|
+
* sorted by name. `body` is true when the route carries the operation's
|
|
132
|
+
* input as a JSON request body. `name` is the registry's dotted operation
|
|
133
|
+
* name.
|
|
134
|
+
*/
|
|
135
|
+
export function indexOperations(openapi) {
|
|
136
|
+
const ops = [];
|
|
137
|
+
for (const [path, methods] of Object.entries(openapi?.paths || {})) {
|
|
138
|
+
for (const [method, op] of Object.entries(methods || {})) {
|
|
139
|
+
// Keyed on the registry's dotted name (x-substrio-operation); operationId
|
|
140
|
+
// is a per-route spelling and not the operation's identity.
|
|
141
|
+
if (!op || typeof op !== 'object' || !op['x-substrio-operation']) continue;
|
|
142
|
+
const route = catalogRoute(path, method, { ...op, parameters: resolveParameters(openapi, op.parameters) });
|
|
143
|
+
ops.push({
|
|
144
|
+
...route,
|
|
145
|
+
name: op['x-substrio-operation'],
|
|
146
|
+
id: op['x-substrio-operation-id'],
|
|
147
|
+
summary: op.summary || op.description || '',
|
|
148
|
+
tags: op.tags || [],
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// By name; an operation's routes keep the server's order, canonical first
|
|
153
|
+
// (the sort is stable).
|
|
154
|
+
ops.sort((a, b) => (a.name === b.name ? 0 : a.name < b.name ? -1 : 1));
|
|
155
|
+
return withoutCollectionRoutes(ops);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The parameters of a route with every {"$ref": "#/components/..."} replaced by
|
|
159
|
+
// what it points at (a JSON pointer into the catalog). A reference that
|
|
160
|
+
// points at nothing is an error: a parameter is never dropped unseen.
|
|
161
|
+
function resolveParameters(openapi, parameters) {
|
|
162
|
+
if (!Array.isArray(parameters)) return parameters;
|
|
163
|
+
return parameters.map((p) => (p && typeof p === 'object' && p.$ref !== undefined ? resolvePointer(openapi, p.$ref) : p));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The value a JSON pointer ("#/components/parameters/X", with ~1 for / and ~0
|
|
167
|
+
// for ~) names in `document`.
|
|
168
|
+
function resolvePointer(document, pointer) {
|
|
169
|
+
const [root, ...keys] = String(pointer).split('/');
|
|
170
|
+
let at = document;
|
|
171
|
+
for (const key of keys) at = at !== null && typeof at === 'object' ? at[key.replaceAll('~1', '/').replaceAll('~0', '~')] : undefined;
|
|
172
|
+
if (root !== '#' || at === undefined || at === null) throw new Error(`the catalog refers to ${pointer}, which it does not hold`);
|
|
173
|
+
return at;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The path parameter that names a collection in the routes `{collection}`. */
|
|
177
|
+
export const COLLECTION_PARAM = 'collection';
|
|
178
|
+
|
|
179
|
+
// Whether `route` is the template route written out for one collection: the
|
|
180
|
+
// same path as `template` with its {collection} segment a literal name.
|
|
181
|
+
function isCollectionRoute(route, template) {
|
|
182
|
+
if (route.method !== template.method || !template.params.includes(COLLECTION_PARAM)) return false;
|
|
183
|
+
const own = route.path.split('/');
|
|
184
|
+
const shape = template.path.split('/');
|
|
185
|
+
if (own.length !== shape.length) return false;
|
|
186
|
+
let named = false;
|
|
187
|
+
for (let i = 0; i < shape.length; i++) {
|
|
188
|
+
if (shape[i] === `{${COLLECTION_PARAM}}`) {
|
|
189
|
+
if (own[i] === shape[i]) return false;
|
|
190
|
+
named = true;
|
|
191
|
+
} else if (own[i] !== shape[i]) return false;
|
|
192
|
+
}
|
|
193
|
+
return named;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The server lists each {collection} route once more for every collection it
|
|
197
|
+
// has (/v1/events/_import beside /v1/{collection}/_import). Those are the same
|
|
198
|
+
// operation, and a route with no path parameter to fill would be picked for
|
|
199
|
+
// an input that names none (inserting into the first collection). The
|
|
200
|
+
// template route covers them: a collection is the `collection` parameter.
|
|
201
|
+
function withoutCollectionRoutes(ops) {
|
|
202
|
+
const templates = ops.filter((op) => op.params.includes(COLLECTION_PARAM));
|
|
203
|
+
return ops.filter((op) => !templates.some((t) => t.name === op.name && isCollectionRoute(op, t)));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// A name in the forms people and agents use: "webhook.get", its MCP tool
|
|
207
|
+
// name "substrio_webhook_get", or "webhook_get".
|
|
208
|
+
const normalize = (name) => name.toLowerCase().replace(/^substrio_/, '').replace(/[._-]/g, '_');
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The routes of the operation `name` names, exact dotted name first, else by
|
|
212
|
+
* normalized name; throws when none or several operations match (pure).
|
|
213
|
+
*/
|
|
214
|
+
export function findOperation(ops, name) {
|
|
215
|
+
let found = ops.filter((op) => op.name === name);
|
|
216
|
+
if (found.length === 0) found = ops.filter((op) => normalize(op.name) === normalize(name));
|
|
217
|
+
const names = [...new Set(found.map((op) => op.name))];
|
|
218
|
+
if (names.length === 0) {
|
|
219
|
+
const near = [...new Set(ops.map((op) => op.name))]
|
|
220
|
+
.filter((n) => n.includes(name.replace(/^substrio_/, '').split(/[._]/)[0])).slice(0, 8);
|
|
221
|
+
throw new Error(`no operation "${name}"${near.length ? `; did you mean ${near.join(', ')}?` : ''} (substrio op list)`);
|
|
222
|
+
}
|
|
223
|
+
if (names.length > 1) throw new Error(`"${name}" matches ${names.join(', ')}; give the dotted name`);
|
|
224
|
+
return found;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The route of an operation to call with `input` (pure). Among its routes
|
|
229
|
+
* (those with `method`, when given) whose path parameters the input provides,
|
|
230
|
+
* the canonical one is the route that carries the operation's input schema
|
|
231
|
+
* as a JSON body: any field fits there, while a query-only alias takes only
|
|
232
|
+
* its declared parameters. Without a body route, the first that fits; when
|
|
233
|
+
* none fits, the first (so its error names what is missing). Throws when no
|
|
234
|
+
* route has the method.
|
|
235
|
+
*/
|
|
236
|
+
export function pickRoute(routes, input, method = null) {
|
|
237
|
+
return pickOperationRoute({ name: routes[0].name, routes }, input, method);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The paging shape every list operation answers in (B313): a page's
|
|
241
|
+
// `next_cursor`, given back as the `cursor` input for the next page.
|
|
242
|
+
export const PAGE_CURSOR_INPUT = 'cursor';
|
|
243
|
+
export const PAGE_NEXT_CURSOR_FIELD = 'next_cursor';
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Two pages of one list as one answer (pure): each array both carry is
|
|
247
|
+
* joined, `count` summed, and `next_cursor` is the later page's.
|
|
248
|
+
*/
|
|
249
|
+
export function mergePages(first, next) {
|
|
250
|
+
const merged = { ...first };
|
|
251
|
+
for (const [key, value] of Object.entries(next)) {
|
|
252
|
+
if (Array.isArray(merged[key]) && Array.isArray(value)) merged[key] = [...merged[key], ...value];
|
|
253
|
+
else if (key === 'count' && typeof merged[key] === 'number' && typeof value === 'number') merged[key] += value;
|
|
254
|
+
else if (key === PAGE_NEXT_CURSOR_FIELD) merged[key] = value;
|
|
255
|
+
}
|
|
256
|
+
return merged;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Whether a list row matches a filter: every word is in its name, path,
|
|
260
|
+
// summary or tags.
|
|
261
|
+
export function matchesFilter(op, filter) {
|
|
262
|
+
if (!filter) return true;
|
|
263
|
+
const hay = `${op.name} ${op.method} ${op.path} ${op.summary} ${op.tags.join(' ')}`.toLowerCase();
|
|
264
|
+
return filter.toLowerCase().split(/\s+/).filter(Boolean).every((word) => hay.includes(word));
|
|
265
|
+
}
|
package/src/readers.mjs
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: the byte-level document readers behind `substrio data import`.
|
|
3
|
+
*
|
|
4
|
+
* Each reader is an async generator over raw Buffer chunks (never decoded
|
|
5
|
+
* text, never JSON.parse'd) that yields {position, raw, endOffset} (`raw` is
|
|
6
|
+
* a Buffer of that document's own JSON text, sent on as the server's bytes:
|
|
7
|
+
* no parse, no object graph, no stringify) or {position, error, endOffset,
|
|
8
|
+
* violations?} for what only the reader can know (a truncated final
|
|
9
|
+
* element, a CSV row with the wrong field count); whether a document's JSON
|
|
10
|
+
* text is well-formed, or an object, is the server's row error to make.
|
|
11
|
+
* endOffset is the absolute byte offset in the input just past that
|
|
12
|
+
* document, for checkpointing. Each holds at most one document's bytes
|
|
13
|
+
* beyond the chunk in flight: a chunk arrives, is appended, and every byte
|
|
14
|
+
* before the current document's start is dropped before the next chunk is
|
|
15
|
+
* pulled.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createReadStream } from 'node:fs';
|
|
19
|
+
|
|
20
|
+
import { CliError, ErrorCode } from './cli.mjs';
|
|
21
|
+
|
|
22
|
+
export const CsvType = Object.freeze({ STRING: 'string', NUMBER: 'number', DATE: 'date', BOOLEAN: 'boolean', JSON: 'json' });
|
|
23
|
+
|
|
24
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One document, or why a value is not one (pure): {position, document, size}
|
|
28
|
+
* or {position, error}. `size` is the document's length in JSON text, which
|
|
29
|
+
* the caller may know already (a line, a byte span), else it is measured.
|
|
30
|
+
*/
|
|
31
|
+
export function documentAt(position, value, size = null) {
|
|
32
|
+
if (isObject(value)) return { position, document: value, size: size ?? JSON.stringify(value).length };
|
|
33
|
+
const kind = Array.isArray(value) ? 'an array' : value === null ? 'null' : `a ${typeof value}`;
|
|
34
|
+
return { position, error: `not a JSON object (it is ${kind})` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One NDJSON line as a document or an error (pure); null for a blank line. */
|
|
38
|
+
export function documentOfLine(position, line) {
|
|
39
|
+
if (line.trim() === '') return null;
|
|
40
|
+
try {
|
|
41
|
+
return documentAt(position, JSON.parse(line), Buffer.byteLength(line));
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return { position, error: `not valid JSON (${err.message})` };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function unreadable(path, err) {
|
|
48
|
+
return new CliError(`${path}: ${err.message}`, { code: err.code === 'ENOENT' ? ErrorCode.FILE_NOT_FOUND : ErrorCode.INVALID_ARGUMENT });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Raw byte chunks of `source` ({path} or {text}), from `start` on. stdin ("-") cannot be sought. */
|
|
52
|
+
export async function* byteChunksOf(source, start = 0) {
|
|
53
|
+
if (source.text !== undefined) {
|
|
54
|
+
const buf = Buffer.from(source.text, 'utf8');
|
|
55
|
+
yield start > 0 ? buf.subarray(start) : buf;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const isStdin = source.path === '-';
|
|
59
|
+
if (isStdin && start > 0) throw new CliError('--resume cannot be used with stdin (--file -): it cannot be sought', { code: ErrorCode.INVALID_ARGUMENT });
|
|
60
|
+
const stream = isStdin ? process.stdin : createReadStream(source.path, start > 0 ? { start } : undefined);
|
|
61
|
+
try {
|
|
62
|
+
for await (const chunk of stream) yield chunk;
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw unreadable(source.path, err);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const BOM0 = 0xEF, BOM1 = 0xBB, BOM2 = 0xBF;
|
|
69
|
+
const QUOTE = 0x22, BACKSLASH = 0x5C, COMMA = 0x2C, OPEN_BRACE = 0x7B, OPEN_BRACKET = 0x5B,
|
|
70
|
+
CLOSE_BRACE = 0x7D, CLOSE_BRACKET = 0x5D, SPACE = 0x20, TAB = 0x09, LF = 0x0A, CR = 0x0D, COLON = 0x3A;
|
|
71
|
+
const isWs = (c) => c === SPACE || c === TAB || c === LF || c === CR;
|
|
72
|
+
|
|
73
|
+
// Whether a byte span is entirely whitespace (a blank line), at the byte level: no decode.
|
|
74
|
+
function isBlank(buf, start, end) {
|
|
75
|
+
for (let k = start; k < end; k++) if (!isWs(buf[k])) return false;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* NDJSON documents of `chunks` (one JSON object per line); `baseOffset` is
|
|
81
|
+
* the absolute byte offset the first chunk starts at (for a resumed read).
|
|
82
|
+
* Each line's own bytes are yielded as `raw`, unparsed: whether it is valid
|
|
83
|
+
* JSON, or an object, is the server's row error to make.
|
|
84
|
+
*/
|
|
85
|
+
export async function* ndjsonDocuments(chunks, { baseOffset = 0 } = {}) {
|
|
86
|
+
let offset = baseOffset;
|
|
87
|
+
let position = 0;
|
|
88
|
+
let pending = Buffer.alloc(0);
|
|
89
|
+
let strippedBOM = baseOffset !== 0;
|
|
90
|
+
for await (const chunk of chunks) {
|
|
91
|
+
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
|
|
92
|
+
let start = 0;
|
|
93
|
+
let nl;
|
|
94
|
+
while ((nl = pending.indexOf(LF, start)) !== -1) {
|
|
95
|
+
let end = nl;
|
|
96
|
+
if (end > start && pending[end - 1] === CR) end--;
|
|
97
|
+
let raw = pending.subarray(start, end);
|
|
98
|
+
if (!strippedBOM) {
|
|
99
|
+
if (raw.length >= 3 && raw[0] === BOM0 && raw[1] === BOM1 && raw[2] === BOM2) raw = raw.subarray(3);
|
|
100
|
+
strippedBOM = true;
|
|
101
|
+
}
|
|
102
|
+
position++;
|
|
103
|
+
offset += nl + 1 - start;
|
|
104
|
+
if (!isBlank(raw, 0, raw.length)) yield { position, raw, endOffset: offset };
|
|
105
|
+
start = nl + 1;
|
|
106
|
+
}
|
|
107
|
+
if (start > 0) pending = pending.subarray(start);
|
|
108
|
+
}
|
|
109
|
+
if (pending.length) {
|
|
110
|
+
position++;
|
|
111
|
+
let raw = pending;
|
|
112
|
+
if (!strippedBOM && raw.length >= 3 && raw[0] === BOM0 && raw[1] === BOM1 && raw[2] === BOM2) raw = raw.subarray(3);
|
|
113
|
+
offset += pending.length;
|
|
114
|
+
if (!isBlank(raw, 0, raw.length)) yield { position, raw, endOffset: offset };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A JSON array of documents, read without ever holding more than one
|
|
120
|
+
* element's text: a byte scanner tracks only string, escape and nesting
|
|
121
|
+
* state to cut each top-level element's span, yielded unparsed as `raw`.
|
|
122
|
+
* Also accepts {"documents": [...]}. `resuming` skips the opening `[`/wrapper
|
|
123
|
+
* (a resumed read lands just past a previous element, expecting `,` or `]`).
|
|
124
|
+
*/
|
|
125
|
+
export async function* jsonArrayDocuments(chunks, { baseOffset = 0, resuming = false } = {}) {
|
|
126
|
+
const pull = chunks[Symbol.asyncIterator]();
|
|
127
|
+
let buf = Buffer.alloc(0);
|
|
128
|
+
let bufBase = baseOffset;
|
|
129
|
+
let i = 0;
|
|
130
|
+
let keepFrom = 0;
|
|
131
|
+
let eof = false;
|
|
132
|
+
|
|
133
|
+
const truncated = () => new CliError(`truncated JSON array: unexpected end of input at byte ${bufBase + i}`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
134
|
+
|
|
135
|
+
async function more() {
|
|
136
|
+
if (eof) return false;
|
|
137
|
+
const { value, done } = await pull.next();
|
|
138
|
+
if (done) { eof = true; return false; }
|
|
139
|
+
const drop = keepFrom;
|
|
140
|
+
buf = Buffer.concat([buf.subarray(drop), value]);
|
|
141
|
+
bufBase += drop;
|
|
142
|
+
i -= drop;
|
|
143
|
+
keepFrom = 0;
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
async function peek() {
|
|
147
|
+
if (i >= buf.length) { if (!(await more())) return -1; }
|
|
148
|
+
return buf[i];
|
|
149
|
+
}
|
|
150
|
+
async function skipWs() {
|
|
151
|
+
for (;;) {
|
|
152
|
+
const c = await peek();
|
|
153
|
+
if (c === -1 || !isWs(c)) return c;
|
|
154
|
+
i++;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function scanStringBody() {
|
|
158
|
+
for (;;) {
|
|
159
|
+
const c = await peek();
|
|
160
|
+
if (c === -1) throw truncated();
|
|
161
|
+
i++;
|
|
162
|
+
if (c === BACKSLASH) {
|
|
163
|
+
if ((await peek()) === -1) throw truncated();
|
|
164
|
+
i++;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (c === QUOTE) return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function scanValue() {
|
|
171
|
+
const c = await peek();
|
|
172
|
+
if (c === -1) throw truncated();
|
|
173
|
+
if (c === QUOTE) { i++; await scanStringBody(); return; }
|
|
174
|
+
if (c === OPEN_BRACE || c === OPEN_BRACKET) {
|
|
175
|
+
let depth = 0;
|
|
176
|
+
do {
|
|
177
|
+
const ch = await peek();
|
|
178
|
+
if (ch === -1) throw truncated();
|
|
179
|
+
if (ch === QUOTE) { i++; await scanStringBody(); continue; }
|
|
180
|
+
if (ch === OPEN_BRACE || ch === OPEN_BRACKET) depth++;
|
|
181
|
+
else if (ch === CLOSE_BRACE || ch === CLOSE_BRACKET) depth--;
|
|
182
|
+
i++;
|
|
183
|
+
} while (depth > 0);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
for (;;) {
|
|
187
|
+
const ch = await peek();
|
|
188
|
+
if (ch === -1 || ch === COMMA || ch === CLOSE_BRACKET || ch === CLOSE_BRACE || isWs(ch)) return;
|
|
189
|
+
i++;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!resuming) {
|
|
194
|
+
if (baseOffset === 0) {
|
|
195
|
+
while (buf.length < 3) { if (!(await more())) break; }
|
|
196
|
+
if (buf.length >= 3 && buf[0] === BOM0 && buf[1] === BOM1 && buf[2] === BOM2) i = 3;
|
|
197
|
+
}
|
|
198
|
+
let c = await skipWs();
|
|
199
|
+
if (c === OPEN_BRACE) {
|
|
200
|
+
i++;
|
|
201
|
+
for (;;) {
|
|
202
|
+
c = await skipWs();
|
|
203
|
+
if (c === CLOSE_BRACE) throw new CliError('a .json file holds an array of objects or {"documents": [...]}; no "documents" field was found', { code: ErrorCode.INVALID_ARGUMENT });
|
|
204
|
+
if (c === -1) throw truncated();
|
|
205
|
+
if (c !== QUOTE) throw new CliError('not valid JSON (expected a field name)', { code: ErrorCode.INVALID_ARGUMENT });
|
|
206
|
+
keepFrom = i;
|
|
207
|
+
i++;
|
|
208
|
+
await scanStringBody();
|
|
209
|
+
const key = buf.subarray(keepFrom + 1, i - 1).toString('utf8');
|
|
210
|
+
c = await skipWs();
|
|
211
|
+
if (c !== COLON) throw new CliError('not valid JSON (expected ":")', { code: ErrorCode.INVALID_ARGUMENT });
|
|
212
|
+
i++;
|
|
213
|
+
await skipWs();
|
|
214
|
+
if (key === 'documents') {
|
|
215
|
+
const vc = await peek();
|
|
216
|
+
if (vc !== OPEN_BRACKET) throw new CliError('"documents" must be an array of objects', { code: ErrorCode.INVALID_ARGUMENT });
|
|
217
|
+
i++;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
keepFrom = i;
|
|
221
|
+
await scanValue();
|
|
222
|
+
c = await skipWs();
|
|
223
|
+
if (c === COMMA) { i++; continue; }
|
|
224
|
+
if (c === CLOSE_BRACE) throw new CliError('a .json file holds an array of objects or {"documents": [...]}; no "documents" field was found', { code: ErrorCode.INVALID_ARGUMENT });
|
|
225
|
+
throw new CliError('not valid JSON (expected "," or "}")', { code: ErrorCode.INVALID_ARGUMENT });
|
|
226
|
+
}
|
|
227
|
+
} else if (c === OPEN_BRACKET) {
|
|
228
|
+
i++;
|
|
229
|
+
} else if (c === -1) {
|
|
230
|
+
throw new CliError('not valid JSON (empty input)', { code: ErrorCode.INVALID_ARGUMENT });
|
|
231
|
+
} else {
|
|
232
|
+
throw new CliError('a .json file holds an array of objects or {"documents": [...]}; for one object per line use .ndjson', { code: ErrorCode.INVALID_ARGUMENT });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let index = 0;
|
|
237
|
+
let seenOne = resuming;
|
|
238
|
+
for (;;) {
|
|
239
|
+
let c = await skipWs();
|
|
240
|
+
if (c === CLOSE_BRACKET) { i++; return; }
|
|
241
|
+
if (seenOne) {
|
|
242
|
+
if (c !== COMMA) throw new CliError(`not valid JSON (expected "," or "]" at byte ${bufBase + i})`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
243
|
+
i++;
|
|
244
|
+
c = await skipWs();
|
|
245
|
+
if (c === CLOSE_BRACKET) { i++; return; }
|
|
246
|
+
}
|
|
247
|
+
seenOne = true;
|
|
248
|
+
if (c === -1) throw truncated();
|
|
249
|
+
index++;
|
|
250
|
+
keepFrom = i;
|
|
251
|
+
await scanValue();
|
|
252
|
+
const raw = buf.subarray(keepFrom, i);
|
|
253
|
+
const endOffset = bufBase + i;
|
|
254
|
+
keepFrom = i;
|
|
255
|
+
// raw is only a balanced-brackets/quoted-string span, unparsed: whether it is well-formed
|
|
256
|
+
// JSON, or an object, is the server's row error to make (mapped back to this position).
|
|
257
|
+
yield { position: index, raw, endOffset };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// A value that failed its declared CSV type.
|
|
262
|
+
const TYPE_ERROR = Symbol('csv-type-error');
|
|
263
|
+
|
|
264
|
+
// A JSON string literal of `s`: the engine's own escaper (one native call; it also escapes a lone
|
|
265
|
+
// surrogate, which a hand-written loop would let through to be replaced when the text is encoded).
|
|
266
|
+
const jsonStringLiteral = (s) => JSON.stringify(s);
|
|
267
|
+
|
|
268
|
+
// A JSON number literal exactly as written: embedded as given, so no digit is lost to a double.
|
|
269
|
+
const JSON_NUMBER_RE = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/;
|
|
270
|
+
|
|
271
|
+
// `raw`'s declared-type value, as JSON text (not a JS value: the row is built as JSON text directly, never
|
|
272
|
+
// an object that is then stringified). A CsvType.JSON field is parsed once and written again on one line.
|
|
273
|
+
function csvFieldJsonText(type, raw) {
|
|
274
|
+
switch (type) {
|
|
275
|
+
case CsvType.STRING: return jsonStringLiteral(raw);
|
|
276
|
+
case CsvType.NUMBER: {
|
|
277
|
+
const text = raw.trim();
|
|
278
|
+
if (JSON_NUMBER_RE.test(text)) return text; // already a JSON number: its own digits, whole
|
|
279
|
+
// Another spelling a number takes (.5, +5, 0x10): the value, written as JSON.
|
|
280
|
+
const n = Number(text);
|
|
281
|
+
return text !== '' && Number.isFinite(n) ? String(n) : TYPE_ERROR;
|
|
282
|
+
}
|
|
283
|
+
case CsvType.BOOLEAN: {
|
|
284
|
+
const low = raw.toLowerCase();
|
|
285
|
+
if (low === 'true') return 'true';
|
|
286
|
+
if (low === 'false') return 'false';
|
|
287
|
+
return TYPE_ERROR;
|
|
288
|
+
}
|
|
289
|
+
case CsvType.DATE: return Number.isNaN(Date.parse(raw)) ? TYPE_ERROR : jsonStringLiteral(raw);
|
|
290
|
+
// Written again from its parse, on one line: the row travels as one NDJSON line, and the field as
|
|
291
|
+
// given may hold a raw newline (pretty-printed JSON in a quoted CSV field) that would split it.
|
|
292
|
+
case CsvType.JSON: try { return JSON.stringify(JSON.parse(raw)); } catch { return TYPE_ERROR; }
|
|
293
|
+
default: return TYPE_ERROR;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* CSV documents of `chunks` (RFC 4180: quoted fields, doubled quotes,
|
|
299
|
+
* embedded newlines, CRLF, a leading BOM; a header row is required). `types`
|
|
300
|
+
* is a Map of field name -> CsvType (absent fields are strings); `emptyAs`,
|
|
301
|
+
* when given, is the value an empty field becomes (absent otherwise).
|
|
302
|
+
* `resumeOffset`: data rows ending at or before it are read (for the
|
|
303
|
+
* header) but not yielded, since the header itself must always be read.
|
|
304
|
+
*/
|
|
305
|
+
export async function* csvDocuments(chunks, { types = null, emptyAs = undefined, resumeOffset = 0 } = {}) {
|
|
306
|
+
const pull = chunks[Symbol.asyncIterator]();
|
|
307
|
+
let buf = Buffer.alloc(0);
|
|
308
|
+
let bufBase = 0;
|
|
309
|
+
let i = 0;
|
|
310
|
+
let keepFrom = 0;
|
|
311
|
+
let fieldStart = 0; // the current field's start, another index into buf: more() must shift it too
|
|
312
|
+
let eof = false;
|
|
313
|
+
|
|
314
|
+
async function more() {
|
|
315
|
+
if (eof) return false;
|
|
316
|
+
const { value, done } = await pull.next();
|
|
317
|
+
if (done) { eof = true; return false; }
|
|
318
|
+
const drop = keepFrom;
|
|
319
|
+
buf = Buffer.concat([buf.subarray(drop), value]);
|
|
320
|
+
bufBase += drop;
|
|
321
|
+
i -= drop;
|
|
322
|
+
fieldStart -= drop;
|
|
323
|
+
keepFrom = 0;
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
async function peek() {
|
|
327
|
+
if (i >= buf.length) { if (!(await more())) return -1; }
|
|
328
|
+
return buf[i];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
while (buf.length < 3) { if (!(await more())) break; }
|
|
332
|
+
if (buf.length >= 3 && buf[0] === BOM0 && buf[1] === BOM1 && buf[2] === BOM2) i = 3;
|
|
333
|
+
keepFrom = i;
|
|
334
|
+
|
|
335
|
+
let header = null;
|
|
336
|
+
let keyTexts = null; // each header name as its JSON key text, escaped once for the file, not once a row
|
|
337
|
+
let dataRow = 0;
|
|
338
|
+
let fields = [];
|
|
339
|
+
let pieces = [];
|
|
340
|
+
fieldStart = i;
|
|
341
|
+
let quoted = false;
|
|
342
|
+
let openedWithQuote = false;
|
|
343
|
+
|
|
344
|
+
// keepFrom tracks fieldStart: nothing before the field in progress is ever needed again, so a
|
|
345
|
+
// compaction (more()) may drop it, bounding memory to the current field plus one incoming chunk.
|
|
346
|
+
function startField() { fieldStart = i; keepFrom = i; pieces = []; quoted = false; openedWithQuote = false; }
|
|
347
|
+
function pushPiece(end) { if (end > fieldStart) pieces.push(Buffer.from(buf.subarray(fieldStart, end))); }
|
|
348
|
+
function fieldText() { return pieces.length === 0 ? '' : Buffer.concat(pieces).toString('utf8'); }
|
|
349
|
+
|
|
350
|
+
function typeOf(name) { return types?.get(name) ?? CsvType.STRING; }
|
|
351
|
+
|
|
352
|
+
function finishRow() {
|
|
353
|
+
const absEnd = bufBase + i;
|
|
354
|
+
const rowFields = fields;
|
|
355
|
+
fields = [];
|
|
356
|
+
if (header === null) { header = rowFields; keyTexts = header.map(jsonStringLiteral); return null; }
|
|
357
|
+
dataRow++;
|
|
358
|
+
if (absEnd <= resumeOffset) return null;
|
|
359
|
+
if (rowFields.length !== header.length) {
|
|
360
|
+
return { position: dataRow, error: `row has ${rowFields.length} field${rowFields.length === 1 ? '' : 's'}, the header has ${header.length}`, endOffset: absEnd };
|
|
361
|
+
}
|
|
362
|
+
const parts = [];
|
|
363
|
+
const violations = [];
|
|
364
|
+
for (let k = 0; k < header.length; k++) {
|
|
365
|
+
const name = header[k];
|
|
366
|
+
const raw = rowFields[k];
|
|
367
|
+
if (raw === '') {
|
|
368
|
+
if (emptyAs !== undefined) parts.push(`${keyTexts[k]}:null`);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const type = typeOf(name);
|
|
372
|
+
const text = csvFieldJsonText(type, raw);
|
|
373
|
+
if (text === TYPE_ERROR) { violations.push({ field: name, message: `is not a valid ${type}: ${JSON.stringify(raw)}` }); continue; }
|
|
374
|
+
parts.push(`${keyTexts[k]}:${text}`);
|
|
375
|
+
}
|
|
376
|
+
if (violations.length) return { position: dataRow, error: `row ${dataRow} does not match its declared types`, violations, endOffset: absEnd };
|
|
377
|
+
return { position: dataRow, raw: Buffer.from(`{${parts.join(',')}}`, 'utf8'), endOffset: absEnd };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
startField();
|
|
381
|
+
for (;;) {
|
|
382
|
+
const c = await peek();
|
|
383
|
+
if (c === -1) {
|
|
384
|
+
if (quoted) { yield { position: dataRow + 1, error: `truncated input: an unterminated quoted field at byte ${bufBase + i}`, endOffset: bufBase + i }; return; }
|
|
385
|
+
pushPiece(i);
|
|
386
|
+
if (fields.length || pieces.length || openedWithQuote) fields.push(fieldText());
|
|
387
|
+
if (fields.length) { const r = finishRow(); if (r !== null) yield r; }
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (quoted) {
|
|
391
|
+
if (c === QUOTE) {
|
|
392
|
+
pushPiece(i); // flush the content before this quote
|
|
393
|
+
i++;
|
|
394
|
+
const n = await peek();
|
|
395
|
+
if (n === QUOTE) { pieces.push(Buffer.from('"')); i++; fieldStart = i; keepFrom = i; continue; }
|
|
396
|
+
quoted = false;
|
|
397
|
+
fieldStart = i;
|
|
398
|
+
keepFrom = i;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
i++;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (c === QUOTE && i === fieldStart && pieces.length === 0) { quoted = true; openedWithQuote = true; i++; fieldStart = i; keepFrom = i; continue; }
|
|
405
|
+
if (c === COMMA) { pushPiece(i); fields.push(fieldText()); i++; startField(); continue; }
|
|
406
|
+
if (c === CR) { pushPiece(i); i++; fieldStart = i; keepFrom = i; continue; }
|
|
407
|
+
if (c === LF) {
|
|
408
|
+
pushPiece(i); fields.push(fieldText()); i++;
|
|
409
|
+
const r = finishRow();
|
|
410
|
+
startField();
|
|
411
|
+
if (r !== null) yield r;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
i++;
|
|
415
|
+
}
|
|
416
|
+
}
|