@functional-systems/lambdadb-cli 0.1.0-dev.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/CHANGELOG.md +45 -0
- package/CONTRIBUTING.md +105 -0
- package/DESIGN.md +112 -0
- package/LICENSE +202 -0
- package/README.md +309 -0
- package/RELEASING.md +239 -0
- package/VALIDATION.md +222 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +208 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.js +105 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +41 -0
- package/dist/errors.js.map +1 -0
- package/dist/import-workflow.d.ts +27 -0
- package/dist/import-workflow.js +60 -0
- package/dist/import-workflow.js.map +1 -0
- package/dist/input.d.ts +15 -0
- package/dist/input.js +127 -0
- package/dist/input.js.map +1 -0
- package/dist/output.d.ts +17 -0
- package/dist/output.js +68 -0
- package/dist/output.js.map +1 -0
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.js +39 -0
- package/dist/runtime.js.map +1 -0
- package/examples/documents.jsonl +3 -0
- package/examples/index-config.json +4 -0
- package/examples/query.json +5 -0
- package/package.json +56 -0
- package/src/cli.ts +209 -0
- package/src/config.ts +96 -0
- package/src/errors.ts +45 -0
- package/src/import-workflow.ts +73 -0
- package/src/input.ts +105 -0
- package/src/output.ts +69 -0
- package/src/runtime.ts +40 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, CommanderError, Option } from 'commander';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { integer, name, resolveConfig, saveSettings, type Options } from './config.js';
|
|
5
|
+
import { createInput, parseRef, queryInput, readJson, readJsonl } from './input.js';
|
|
6
|
+
import { importDocuments, planBatches } from './import-workflow.js';
|
|
7
|
+
import { definitelyRejected, describeError, InputError, isInputError } from './errors.js';
|
|
8
|
+
import { Output, type Envelope } from './output.js';
|
|
9
|
+
import { runtime } from './runtime.js';
|
|
10
|
+
|
|
11
|
+
const output = new Output();
|
|
12
|
+
output.protect(process.env.LAMBDADB_API_KEY);
|
|
13
|
+
if (process.env.LAMBDADB_API_KEY_ENV) output.protect(process.env[process.env.LAMBDADB_API_KEY_ENV]);
|
|
14
|
+
let commandName = 'lambdadb';
|
|
15
|
+
let target: Record<string, unknown> | undefined;
|
|
16
|
+
let mutationStarted = false;
|
|
17
|
+
|
|
18
|
+
const program = new Command()
|
|
19
|
+
.name('lambdadb')
|
|
20
|
+
.description('Project-scoped LambdaDB CLI. All commands are non-interactive.')
|
|
21
|
+
.version(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version)
|
|
22
|
+
.option('--json', 'Emit one versioned JSON result on stdout; diagnostics stay on stderr')
|
|
23
|
+
.option('--endpoint <url>', 'API origin (overrides LAMBDADB_ENDPOINT and saved configuration)')
|
|
24
|
+
.option('--project <name>', 'Existing project (overrides LAMBDADB_PROJECT and saved configuration)')
|
|
25
|
+
.option('--api-key-env <name>', 'Environment variable holding the key; never pass a key as an argument')
|
|
26
|
+
.option('--config <path>', 'Configuration path (overrides LAMBDADB_CONFIG)')
|
|
27
|
+
.option('--timeout-ms <ms>', 'Total network budget, 1–3600000 ms (default 30000)')
|
|
28
|
+
.showSuggestionAfterError(false)
|
|
29
|
+
.exitOverride()
|
|
30
|
+
.configureOutput({ writeErr: () => {} })
|
|
31
|
+
.addHelpText('after', '\nExamples:\n lambdadb configure --endpoint https://api.lambdadb.ai --project my-project\n lambdadb doctor --json\n lambdadb query --collection demo-docs --ref branch:main --file examples/query.json --json\n\nExit codes: 0 success, 2 input/config error, 3 API/request failure, 4 partial import, 5 unknown write outcome.');
|
|
32
|
+
|
|
33
|
+
function result(data: unknown, summary: string, exitCode = 0) {
|
|
34
|
+
const envelope: Envelope = { schemaVersion: 1, command: commandName, ok: exitCode === 0, target, data };
|
|
35
|
+
output.emit(envelope, summary);
|
|
36
|
+
process.exitCode = exitCode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Context = ReturnType<typeof runtime>;
|
|
40
|
+
async function connected(command: Command, action: (ctx: Context) => Promise<void>) {
|
|
41
|
+
const config = await resolveConfig(command.optsWithGlobals());
|
|
42
|
+
output.protect(config.apiKey);
|
|
43
|
+
target = { endpoint: config.endpoint, project: config.project };
|
|
44
|
+
const ctx = runtime(config);
|
|
45
|
+
try { await action(ctx); } finally { ctx.close(); }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function scoped(command: Command): string {
|
|
49
|
+
const collection = name(command.opts().collection, 'Collection name');
|
|
50
|
+
target = { ...target, collection };
|
|
51
|
+
return collection;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
program.hook('preAction', (_parent, command) => {
|
|
55
|
+
output.json = !!command.optsWithGlobals().json;
|
|
56
|
+
commandName = command.name();
|
|
57
|
+
if (command.parent && command.parent !== program) commandName = `${command.parent.name()} ${commandName}`;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
program.command('configure')
|
|
61
|
+
.description('Save endpoint, project and API-key environment variable name (no secret is saved)')
|
|
62
|
+
.action(async (_opts, cmd: Command) => {
|
|
63
|
+
const saved = await saveSettings(cmd.optsWithGlobals() as Options);
|
|
64
|
+
result(saved, 'Configuration saved. Supply the key in the selected environment variable, then run doctor.');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
program.command('doctor')
|
|
68
|
+
.description('Check configuration, authentication and project collection-list access')
|
|
69
|
+
.action(async (_opts, cmd: Command) => connected(cmd, async ({ client, options }) => {
|
|
70
|
+
await client.listCollections({ size: 1 }, options);
|
|
71
|
+
result({ checks: [
|
|
72
|
+
{ name: 'configuration', status: 'passed' },
|
|
73
|
+
{ name: 'authentication_and_project_collection_list', status: 'passed' },
|
|
74
|
+
], writesVerified: false, queryReadinessVerified: false }, 'Connection verified using the project collection-list API.');
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
const collections = program.command('collections').description('List, describe and create collections');
|
|
78
|
+
collections.command('list')
|
|
79
|
+
.description('Fetch one page by default, or use the SDK iterator for all pages')
|
|
80
|
+
.option('--size <count>', 'Page size, 1–100', '20')
|
|
81
|
+
.option('--page-token <token>', 'Opaque continuation token')
|
|
82
|
+
.option('--all', 'Fetch all pages within the total timeout (results are buffered)')
|
|
83
|
+
.action(async (opts, cmd: Command) => {
|
|
84
|
+
const size = integer(opts.size, 'Page size', 1, 100);
|
|
85
|
+
await connected(cmd, async ({ client, options }) => {
|
|
86
|
+
const params = { size, pageToken: opts.pageToken };
|
|
87
|
+
if (!opts.all) {
|
|
88
|
+
const page = await client.listCollections(params, options);
|
|
89
|
+
result(page, `${page.collections.length} collection(s)${page.nextPageToken ? '; more pages available' : ''}.`);
|
|
90
|
+
} else {
|
|
91
|
+
const items = [];
|
|
92
|
+
for await (const page of client.listCollectionsPages(params, options)) items.push(...page.collections);
|
|
93
|
+
result({ collections: items }, `${items.length} collection(s).`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
collections.command('describe')
|
|
99
|
+
.description('Get collection metadata; counts describe the committed main branch')
|
|
100
|
+
.requiredOption('--collection <name>', 'Collection name')
|
|
101
|
+
.action(async (_opts, cmd: Command) => connected(cmd, async ({ client, options }) => {
|
|
102
|
+
const collection = scoped(cmd);
|
|
103
|
+
result(await client.collection(collection).get(options), 'Collection metadata (main-branch counts are not a readiness signal).');
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
collections.command('create')
|
|
107
|
+
.description('Create a collection from a JSON field-to-index map')
|
|
108
|
+
.requiredOption('--collection <name>', 'New collection name')
|
|
109
|
+
.requiredOption('--index-config <path>', 'JSON indexConfigs map (see examples/index-config.json)')
|
|
110
|
+
.action(async (opts, cmd: Command) => {
|
|
111
|
+
const input = createInput(name(opts.collection, 'Collection name'), await readJson(opts.indexConfig));
|
|
112
|
+
await connected(cmd, async ({ client, options }) => {
|
|
113
|
+
scoped(cmd);
|
|
114
|
+
mutationStarted = true;
|
|
115
|
+
const created = await client.createCollection(input, { ...options, retries: { strategy: 'none' } });
|
|
116
|
+
result({ ...created, state: 'created', searchable: 'not_verified' }, 'Collection created; query-serving readiness has not been verified.');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const docs = program.command('docs').description('Import JSONL and fetch documents by ID');
|
|
121
|
+
docs.command('import')
|
|
122
|
+
.description('Validate JSONL, submit sequential batches, stop on first error; acceptance is not search readiness')
|
|
123
|
+
.requiredOption('--collection <name>', 'Collection name')
|
|
124
|
+
.requiredOption('--branch <name>', 'Explicit writable branch (for example main)')
|
|
125
|
+
.requiredOption('--file <path>', 'UTF-8 JSONL file, at most 64 MiB and 100000 documents')
|
|
126
|
+
.addOption(new Option('--mode <mode>', 'SDK write method').choices(['upsert', 'bulk']).default('upsert'))
|
|
127
|
+
.option('--batch-size <count>', 'Maximum documents per batch, 1–10000', '1000')
|
|
128
|
+
.option('--batch-bytes <bytes>', 'Serialized byte cap (default 4000000 upsert, 16000000 bulk)')
|
|
129
|
+
.action(async (opts, cmd: Command) => {
|
|
130
|
+
name(opts.collection, 'Collection name');
|
|
131
|
+
const branch = name(opts.branch, 'Branch name');
|
|
132
|
+
const bulk = opts.mode === 'bulk';
|
|
133
|
+
const batchSize = integer(opts.batchSize, 'Batch size', 1, 10000);
|
|
134
|
+
const maxBytes = integer(opts.batchBytes ?? (bulk ? '16000000' : '4000000'), 'Batch bytes', 64, bulk ? 64000000 : 4000000);
|
|
135
|
+
const rows = await readJsonl(opts.file);
|
|
136
|
+
const batches = planBatches(rows, branch, batchSize, maxBytes, bulk);
|
|
137
|
+
await connected(cmd, async ({ client, options }) => {
|
|
138
|
+
const collection = scoped(cmd);
|
|
139
|
+
target = { ...target, branch };
|
|
140
|
+
output.diagnostic(`Validated ${rows.length} document(s); submitting ${batches.length} batch(es) using ${opts.mode}.`);
|
|
141
|
+
const imported = await importDocuments(client.collection(collection).docs, batches, branch, bulk, options, batch => {
|
|
142
|
+
output.diagnostic(`Batch ${batch.batch}: ${batch.state}, ${batch.documents} document(s), lines ${batch.firstLine}–${batch.lastLine}.`);
|
|
143
|
+
});
|
|
144
|
+
const exitCode = imported.state === 'accepted' ? 0 : imported.state === 'unknown' ? 5 : imported.state === 'partial' ? 4 : 3;
|
|
145
|
+
result(imported, `${imported.accepted}/${imported.total} document(s) accepted; search visibility not verified.`, exitCode);
|
|
146
|
+
if (exitCode) output.diagnostic('Import stopped. Inspect batch outcomes and the selected branch before retrying; automatic resume is not supported.');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
docs.command('fetch')
|
|
151
|
+
.description('Fetch up to 100 IDs at an explicit ref; missing IDs are a successful empty/partial result')
|
|
152
|
+
.requiredOption('--collection <name>', 'Collection name')
|
|
153
|
+
.requiredOption('--ref <kind:name>', 'branch:NAME, tag:NAME or alias:NAME')
|
|
154
|
+
.requiredOption('--ids <id...>', 'One or more IDs (separate with spaces)')
|
|
155
|
+
.option('--consistent-read', 'Overlay eligible pending writes; direct branch only, excludes bulk imports')
|
|
156
|
+
.option('--include-vectors', 'Include vector fields in returned documents')
|
|
157
|
+
.action(async (opts, cmd: Command) => {
|
|
158
|
+
const ref = parseRef(opts.ref);
|
|
159
|
+
if (opts.ids.length > 100 || opts.ids.some((id: string) => !id)) throw new InputError('Fetch requires 1–100 nonempty IDs.');
|
|
160
|
+
if (opts.consistentRead && ref.kind !== 'branch') throw new InputError('Consistent reads require a direct branch ref.');
|
|
161
|
+
await connected(cmd, async ({ client, options }) => {
|
|
162
|
+
const collection = scoped(cmd);
|
|
163
|
+
target = { ...target, ref };
|
|
164
|
+
const fetched = await client.collection(collection).docs.fetch({
|
|
165
|
+
ids: opts.ids, includeVectors: opts.includeVectors ?? false,
|
|
166
|
+
...(ref.kind === 'branch' ? { ref, consistentRead: !!opts.consistentRead } : { ref, consistentRead: false }),
|
|
167
|
+
}, options);
|
|
168
|
+
const found = new Set(fetched.docs.map(item => item.doc.id));
|
|
169
|
+
const { docsUrl: _signedUrl, ...data } = fetched;
|
|
170
|
+
result({ ...data, missingIds: opts.ids.filter((id: string) => !found.has(id)) }, `${fetched.docs.length} document(s) fetched.`);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
program.command('query')
|
|
175
|
+
.description('Submit a supported query request JSON file; empty matches are a successful result')
|
|
176
|
+
.requiredOption('--collection <name>', 'Collection name')
|
|
177
|
+
.requiredOption('--ref <kind:name>', 'branch:NAME, tag:NAME or alias:NAME; must match any ref in the file')
|
|
178
|
+
.requiredOption('--file <path>', 'JSON request body including query (see examples/query.json)')
|
|
179
|
+
.action(async (opts, cmd: Command) => {
|
|
180
|
+
const ref = parseRef(opts.ref);
|
|
181
|
+
const input = queryInput(await readJson(opts.file), ref);
|
|
182
|
+
await connected(cmd, async ({ client, options }) => {
|
|
183
|
+
const collection = scoped(cmd);
|
|
184
|
+
target = { ...target, ref };
|
|
185
|
+
const queried = await client.collection(collection).query(input, options);
|
|
186
|
+
const { docsUrl: _signedUrl, ...data } = queried;
|
|
187
|
+
result(data, `${queried.docs.length} match(es).`);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
await program.parseAsync(process.argv);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error instanceof CommanderError && error.exitCode === 0) {
|
|
195
|
+
process.exitCode = 0;
|
|
196
|
+
} else {
|
|
197
|
+
output.json ||= process.argv.includes('--json');
|
|
198
|
+
const failure = error instanceof CommanderError
|
|
199
|
+
? new InputError('Invalid or incomplete command. Run lambdadb --help or the command-specific --help.') : error;
|
|
200
|
+
const unknownWrite = mutationStarted && !definitelyRejected(failure);
|
|
201
|
+
const exitCode = isInputError(failure) ? 2 : unknownWrite ? 5 : 3;
|
|
202
|
+
output.emit({
|
|
203
|
+
schemaVersion: 1, command: commandName, ok: false, target,
|
|
204
|
+
...(unknownWrite ? { data: { state: 'unknown', searchable: 'not_verified' } } : {}),
|
|
205
|
+
error: describeError(failure),
|
|
206
|
+
}, unknownWrite ? 'Write outcome unknown. Inspect the target before retrying.' : 'Command failed.');
|
|
207
|
+
process.exitCode = exitCode;
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { InputError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
export type Settings = { endpoint?: string; project?: string; apiKeyEnv?: string };
|
|
8
|
+
export type Config = Required<Settings> & { apiKey: string; timeoutMs: number };
|
|
9
|
+
export type Options = Settings & { config?: string; timeoutMs?: string };
|
|
10
|
+
|
|
11
|
+
export function configPath(options: Options): string {
|
|
12
|
+
return resolve(options.config ?? process.env.LAMBDADB_CONFIG
|
|
13
|
+
?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'lambdadb', 'config.json'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function endpoint(value: string): string {
|
|
17
|
+
let url: URL;
|
|
18
|
+
try { url = new URL(value); } catch { throw new InputError('Endpoint must be an absolute HTTPS origin.'); }
|
|
19
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
20
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))
|
|
21
|
+
|| url.username || url.password || url.search || url.hash || url.pathname !== '/') {
|
|
22
|
+
throw new InputError('Endpoint must be an HTTPS origin without credentials, path, query or fragment (HTTP loopback is allowed for local tests).');
|
|
23
|
+
}
|
|
24
|
+
return url.origin;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function name(value: unknown, label: string): string {
|
|
28
|
+
if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{3,52}$/.test(value)) {
|
|
29
|
+
throw new InputError(`${label} must contain 3–52 letters, digits, underscores or hyphens.`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function environmentName(value: string): string {
|
|
35
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new InputError('API-key environment variable name is invalid.');
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function integer(value: string, label: string, min: number, max: number): number {
|
|
40
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < min || Number(value) > max) {
|
|
41
|
+
throw new InputError(`${label} must be an integer from ${min} to ${max}.`);
|
|
42
|
+
}
|
|
43
|
+
return Number(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function loadSettings(path: string, explicitlySelected = false): Promise<Settings> {
|
|
47
|
+
let text: string;
|
|
48
|
+
try { text = await readFile(path, 'utf8'); }
|
|
49
|
+
catch (error) {
|
|
50
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT' && !explicitlySelected) return {};
|
|
51
|
+
throw new InputError('Cannot read the selected configuration file.');
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const value = JSON.parse(text);
|
|
55
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
56
|
+
|| Object.keys(value).some(k => !['endpoint', 'project', 'apiKeyEnv'].includes(k))
|
|
57
|
+
|| Object.values(value).some(v => typeof v !== 'string')) throw new Error();
|
|
58
|
+
return value;
|
|
59
|
+
} catch { throw new InputError('Configuration must be a JSON object with only endpoint, project and apiKeyEnv strings. Never store an API key in it.'); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function settings(options: Options, creating = false): Promise<Required<Settings>> {
|
|
63
|
+
const saved = await loadSettings(configPath(options), !creating && !!(options.config ?? process.env.LAMBDADB_CONFIG));
|
|
64
|
+
const selectedEndpoint = options.endpoint ?? process.env.LAMBDADB_ENDPOINT ?? saved.endpoint;
|
|
65
|
+
const project = options.project ?? process.env.LAMBDADB_PROJECT ?? saved.project;
|
|
66
|
+
if (!selectedEndpoint || !project) throw new InputError('Endpoint and project are required. Run configure or set LAMBDADB_ENDPOINT and LAMBDADB_PROJECT.');
|
|
67
|
+
// Project names are encoded as a single SDK URL segment, with no implicit playground fallback.
|
|
68
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(project)) throw new InputError('Project must contain only letters, digits, underscores or hyphens.');
|
|
69
|
+
return {
|
|
70
|
+
endpoint: endpoint(selectedEndpoint), project,
|
|
71
|
+
apiKeyEnv: environmentName(options.apiKeyEnv ?? process.env.LAMBDADB_API_KEY_ENV ?? saved.apiKeyEnv ?? 'LAMBDADB_API_KEY'),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function resolveConfig(options: Options): Promise<Config> {
|
|
76
|
+
const selected = await settings(options);
|
|
77
|
+
const apiKey = process.env[selected.apiKeyEnv];
|
|
78
|
+
if (!apiKey || !apiKey.trim() || /[\r\n]/.test(apiKey)) throw new InputError('The selected API-key environment variable is missing or invalid. Supply it through your shell or CI secret manager.');
|
|
79
|
+
return {
|
|
80
|
+
...selected, apiKey,
|
|
81
|
+
timeoutMs: integer(options.timeoutMs ?? process.env.LAMBDADB_TIMEOUT_MS ?? '30000', 'Timeout', 1, 3600000),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function saveSettings(options: Options): Promise<{ path: string; settings: Required<Settings> }> {
|
|
86
|
+
const value = await settings(options, true);
|
|
87
|
+
const path = configPath(options);
|
|
88
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
89
|
+
try {
|
|
90
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
91
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
92
|
+
await rename(temp, path);
|
|
93
|
+
} catch { throw new InputError('Cannot save configuration. Check the selected path and directory permissions.'); }
|
|
94
|
+
finally { await rm(temp, { force: true }).catch(() => {}); }
|
|
95
|
+
return { path, settings: value };
|
|
96
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { LambdaDBError, ResponseValidationError, SDKValidationError } from '@functional-systems/lambdadb/models/errors';
|
|
2
|
+
|
|
3
|
+
export class InputError extends Error {}
|
|
4
|
+
|
|
5
|
+
export type SafeError = { code: string; message: string; hint: string; httpStatus?: number };
|
|
6
|
+
|
|
7
|
+
// Never serialize SDK errors: they can contain credentials, response bodies and signed URLs.
|
|
8
|
+
export function describeError(error: unknown): SafeError {
|
|
9
|
+
if (error instanceof InputError) {
|
|
10
|
+
return { code: 'INPUT_ERROR', message: error.message, hint: 'Check --help and the input file or configuration.' };
|
|
11
|
+
}
|
|
12
|
+
if (error instanceof ResponseValidationError) {
|
|
13
|
+
return { code: 'INVALID_RESPONSE', message: 'The API response does not match the SDK contract.', hint: 'Check SDK/API compatibility. A malformed write response does not prove the write failed.', httpStatus: error.statusCode };
|
|
14
|
+
}
|
|
15
|
+
if (error instanceof SDKValidationError) {
|
|
16
|
+
return { code: 'INPUT_ERROR', message: 'The request does not match the SDK contract.', hint: 'Check field names, types, index settings and ref selection.' };
|
|
17
|
+
}
|
|
18
|
+
if (error instanceof LambdaDBError) {
|
|
19
|
+
const status = error.statusCode;
|
|
20
|
+
const hint = status === 401 || status === 403
|
|
21
|
+
? 'Verify the selected API-key environment variable and its access to this project.'
|
|
22
|
+
: status === 404 ? 'Verify endpoint, project, collection and ref names.'
|
|
23
|
+
: status === 409 ? 'Inspect the existing resource before choosing a different name.'
|
|
24
|
+
: status === 429 ? 'Reduce load or retry later; a consistent-read overlay may exceed its limit.'
|
|
25
|
+
: status === 413 ? 'Reduce the batch size or use bulk mode where supported.'
|
|
26
|
+
: status >= 500 ? 'Check service availability. Inspect writes before retrying an uncertain mutation.'
|
|
27
|
+
: 'Check the request against the public API contract and collection schema.';
|
|
28
|
+
return { code: status === 401 || status === 403 ? 'AUTH_ERROR' : 'API_ERROR', message: `LambdaDB returned HTTP ${status}.`, httpStatus: status, hint };
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
code: 'REQUEST_FAILED',
|
|
32
|
+
message: 'The operation could not be completed or its response could not be verified.',
|
|
33
|
+
hint: 'Check endpoint, connectivity, timeout and SDK/API compatibility. Inspect writes before retrying.',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isInputError(error: unknown): boolean {
|
|
38
|
+
// SDKValidationError uses Symbol.hasInstance and also matches response validation errors.
|
|
39
|
+
return error instanceof InputError || (error instanceof SDKValidationError && !(error instanceof ResponseValidationError));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function definitelyRejected(error: unknown): boolean {
|
|
43
|
+
return isInputError(error) || (error instanceof LambdaDBError
|
|
44
|
+
&& error.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 408);
|
|
45
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ClientRequestOptions, CollectionDocs } from '@functional-systems/lambdadb';
|
|
2
|
+
import { definitelyRejected, describeError, InputError, type SafeError } from './errors.js';
|
|
3
|
+
import type { DocumentRow } from './input.js';
|
|
4
|
+
|
|
5
|
+
export type Batch = { rows: DocumentRow[]; bytes: number };
|
|
6
|
+
export type BatchResult = {
|
|
7
|
+
batch: number; firstLine: number; lastLine: number; documents: number;
|
|
8
|
+
state: 'accepted' | 'failed' | 'unknown'; error?: SafeError;
|
|
9
|
+
};
|
|
10
|
+
export type ImportResult = {
|
|
11
|
+
state: 'accepted' | 'failed' | 'partial' | 'unknown';
|
|
12
|
+
total: number; accepted: number; failed: number; unknown: number; notAttempted: number;
|
|
13
|
+
searchable: 'not_verified'; batches: BatchResult[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function planBatches(rows: DocumentRow[], branch: string, batchSize: number, maxBytes: number, bulk: boolean): Batch[] {
|
|
17
|
+
const overhead = Buffer.byteLength(JSON.stringify(bulk ? { docs: [] } : { docs: [], branch }));
|
|
18
|
+
const batches: Batch[] = [];
|
|
19
|
+
let current: Batch = { rows: [], bytes: overhead };
|
|
20
|
+
for (const row of rows) {
|
|
21
|
+
if (overhead + row.bytes > maxBytes) throw new InputError(`JSONL line ${row.line} exceeds the batch byte limit. Increase the limit within the mode's supported range.`);
|
|
22
|
+
if (current.rows.length && (current.rows.length >= batchSize || current.bytes + 1 + row.bytes > maxBytes)) {
|
|
23
|
+
batches.push(current);
|
|
24
|
+
current = { rows: [], bytes: overhead };
|
|
25
|
+
}
|
|
26
|
+
current.bytes += row.bytes + (current.rows.length ? 1 : 0);
|
|
27
|
+
current.rows.push(row);
|
|
28
|
+
}
|
|
29
|
+
if (current.rows.length) batches.push(current);
|
|
30
|
+
return batches;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Domain workflow: no terminal I/O. Stop on the first failure, preserving acknowledged progress.
|
|
34
|
+
export async function importDocuments(
|
|
35
|
+
docs: Pick<CollectionDocs, 'upsert' | 'bulkUpsertDocs'>,
|
|
36
|
+
batches: Batch[], branch: string, bulk: boolean, options: ClientRequestOptions,
|
|
37
|
+
progress: (batch: BatchResult) => void = () => {},
|
|
38
|
+
): Promise<ImportResult> {
|
|
39
|
+
const total = batches.reduce((sum, b) => sum + b.rows.length, 0);
|
|
40
|
+
const result: ImportResult = {
|
|
41
|
+
state: 'accepted', total, accepted: 0, failed: 0, unknown: 0, notAttempted: total,
|
|
42
|
+
searchable: 'not_verified', batches: [],
|
|
43
|
+
};
|
|
44
|
+
for (const [index, batch] of batches.entries()) {
|
|
45
|
+
if (options.signal?.aborted) {
|
|
46
|
+
result.state = result.accepted > 0 ? 'partial' : 'failed';
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
const item: BatchResult = {
|
|
50
|
+
batch: index + 1, firstLine: batch.rows[0]!.line, lastLine: batch.rows.at(-1)!.line,
|
|
51
|
+
documents: batch.rows.length, state: 'accepted',
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
const body = { docs: batch.rows.map(row => row.doc), branch };
|
|
55
|
+
// Mutations are never automatically retried: no public idempotency/receipt contract.
|
|
56
|
+
const requestOptions: ClientRequestOptions = { ...options, retries: { strategy: 'none' } };
|
|
57
|
+
if (bulk) await docs.bulkUpsertDocs(body, requestOptions);
|
|
58
|
+
else await docs.upsert(body, requestOptions);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
item.state = definitelyRejected(error) ? 'failed' : 'unknown';
|
|
61
|
+
item.error = describeError(error);
|
|
62
|
+
}
|
|
63
|
+
result[item.state] += item.documents;
|
|
64
|
+
result.notAttempted -= item.documents;
|
|
65
|
+
result.batches.push(item);
|
|
66
|
+
progress(item);
|
|
67
|
+
if (item.state !== 'accepted') {
|
|
68
|
+
result.state = item.state === 'unknown' ? 'unknown' : result.accepted > 0 ? 'partial' : 'failed';
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
package/src/input.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import { TextDecoder } from 'node:util';
|
|
3
|
+
import type { CreateCollectionInput, QueryCollectionInput, ReadRef } from '@functional-systems/lambdadb';
|
|
4
|
+
import { createCollectionRequestToJSON, queryCollectionRequestBodyToJSON } from '@functional-systems/lambdadb/models/operations';
|
|
5
|
+
import { InputError } from './errors.js';
|
|
6
|
+
import { name } from './config.js';
|
|
7
|
+
|
|
8
|
+
export const MAX_INPUT_BYTES = 64 * 1024 * 1024;
|
|
9
|
+
export const MAX_INPUT_DOCUMENTS = 100_000;
|
|
10
|
+
|
|
11
|
+
export async function readText(path: string, limit = MAX_INPUT_BYTES): Promise<string> {
|
|
12
|
+
let file;
|
|
13
|
+
try { file = await open(path, 'r'); }
|
|
14
|
+
catch { throw new InputError('Cannot open input file.'); }
|
|
15
|
+
try {
|
|
16
|
+
if (!(await file.stat()).isFile()) throw new InputError('Input must be a regular file.');
|
|
17
|
+
// Read one immutable in-memory input snapshot; never submit a partially validated file.
|
|
18
|
+
const chunks: Buffer[] = [];
|
|
19
|
+
let size = 0;
|
|
20
|
+
for await (const chunk of file.createReadStream({ autoClose: false })) {
|
|
21
|
+
size += chunk.length;
|
|
22
|
+
if (size > limit) throw new InputError(`Input exceeds the ${limit}-byte local limit. Split the file first.`);
|
|
23
|
+
chunks.push(chunk as Buffer);
|
|
24
|
+
}
|
|
25
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error instanceof InputError) throw error;
|
|
28
|
+
throw new InputError('Cannot read input as UTF-8.');
|
|
29
|
+
} finally { await file.close(); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function object(value: unknown, label: string): Record<string, unknown> {
|
|
33
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new InputError(`${label} must be a JSON object.`);
|
|
34
|
+
return value as Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readJson(path: string): Promise<Record<string, unknown>> {
|
|
38
|
+
const text = await readText(path, 4 * 1024 * 1024);
|
|
39
|
+
let value: unknown;
|
|
40
|
+
try { value = JSON.parse(text); } catch { throw new InputError('Input file contains invalid JSON.'); }
|
|
41
|
+
return object(value, 'Input');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseRef(value: string): ReadRef {
|
|
45
|
+
const [kind, refName, extra] = value.split(':');
|
|
46
|
+
if (!['branch', 'tag', 'alias'].includes(kind ?? '') || extra !== undefined) {
|
|
47
|
+
throw new InputError('Ref must be branch:NAME, tag:NAME or alias:NAME.');
|
|
48
|
+
}
|
|
49
|
+
return { kind: kind as ReadRef['kind'], name: name(refName, 'Ref name') };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function queryInput(body: Record<string, unknown>, ref: ReadRef): QueryCollectionInput {
|
|
53
|
+
if (body.ref !== undefined) {
|
|
54
|
+
const supplied = object(body.ref, 'File ref');
|
|
55
|
+
if (Object.keys(supplied).some(k => !['kind', 'name'].includes(k)) || supplied.kind !== ref.kind || supplied.name !== ref.name) {
|
|
56
|
+
throw new InputError('File ref conflicts with --ref. Use the same explicit target in both.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const input: Record<string, unknown> = { ...body, ref };
|
|
60
|
+
// Public API accepts null size, while SDK 0.5.1 only accepts undefined/number.
|
|
61
|
+
if (input.size === null) delete input.size;
|
|
62
|
+
if (input.size !== undefined && (!Number.isInteger(input.size) || Number(input.size) < 1 || Number(input.size) > 100)) {
|
|
63
|
+
throw new InputError('Query size must be an integer from 1 to 100, null or omitted.');
|
|
64
|
+
}
|
|
65
|
+
try { queryCollectionRequestBodyToJSON(input as QueryCollectionInput); }
|
|
66
|
+
catch { throw new InputError('Invalid query request. Check request fields and types; consistentRead:true requires a branch ref.'); }
|
|
67
|
+
return input as QueryCollectionInput;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createInput(collectionName: string, indexConfigs: Record<string, unknown>): CreateCollectionInput {
|
|
71
|
+
const input = { collectionName, indexConfigs } as CreateCollectionInput;
|
|
72
|
+
try { createCollectionRequestToJSON(input); }
|
|
73
|
+
catch { throw new InputError('Invalid index configuration. Provide a nonempty field-to-index map matching the SDK contract.'); }
|
|
74
|
+
return input;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type DocumentRow = { line: number; doc: Record<string, unknown>; bytes: number };
|
|
78
|
+
|
|
79
|
+
export async function readJsonl(path: string): Promise<DocumentRow[]> {
|
|
80
|
+
const text = await readText(path);
|
|
81
|
+
const rows: DocumentRow[] = [];
|
|
82
|
+
// Do not allocate an array entry for every physical line (including blank ones).
|
|
83
|
+
let offset = 0;
|
|
84
|
+
let lineNumber = 0;
|
|
85
|
+
while (offset < text.length) {
|
|
86
|
+
const newline = text.indexOf('\n', offset);
|
|
87
|
+
const end = newline === -1 ? text.length : newline;
|
|
88
|
+
const line = text.slice(offset, end);
|
|
89
|
+
offset = end + 1;
|
|
90
|
+
lineNumber++;
|
|
91
|
+
if (!line.trim()) continue;
|
|
92
|
+
if (rows.length >= MAX_INPUT_DOCUMENTS) {
|
|
93
|
+
throw new InputError(`JSONL exceeds the ${MAX_INPUT_DOCUMENTS}-document local limit at line ${lineNumber}. Split the file first.`);
|
|
94
|
+
}
|
|
95
|
+
let doc: Record<string, unknown>;
|
|
96
|
+
try { doc = object(JSON.parse(line), 'Document'); }
|
|
97
|
+
catch { throw new InputError(`JSONL line ${lineNumber} must be a JSON object.`); }
|
|
98
|
+
if ('id' in doc && (typeof doc.id !== 'string' || doc.id.length === 0)) {
|
|
99
|
+
throw new InputError(`JSONL line ${lineNumber} has an invalid id; use a nonempty string or omit it.`);
|
|
100
|
+
}
|
|
101
|
+
rows.push({ line: lineNumber, doc, bytes: Buffer.byteLength(JSON.stringify(doc)) });
|
|
102
|
+
}
|
|
103
|
+
if (rows.length === 0) throw new InputError('JSONL input contains no documents.');
|
|
104
|
+
return rows;
|
|
105
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { SafeError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export type Envelope = {
|
|
4
|
+
schemaVersion: 1; command: string; ok: boolean;
|
|
5
|
+
target?: Record<string, unknown>; data?: unknown; error?: SafeError;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// These values are CLI protocol tokens, never arbitrary API or document data.
|
|
9
|
+
const protocolValues = new Set([
|
|
10
|
+
'command', 'error.code', 'target.ref.kind',
|
|
11
|
+
'data.state', 'data.searchable', 'data.batches.*.state', 'data.batches.*.error.code',
|
|
12
|
+
'data.checks.*.name', 'data.checks.*.status',
|
|
13
|
+
]);
|
|
14
|
+
// These document/metadata maps carry user-defined field names in MVP responses.
|
|
15
|
+
const freeFormMaps = new Set(['doc', 'indexConfigs', 'tags']);
|
|
16
|
+
|
|
17
|
+
export class Output {
|
|
18
|
+
json = false;
|
|
19
|
+
private secrets = new Set<string>();
|
|
20
|
+
|
|
21
|
+
protect(secret: string | undefined) { if (secret) this.secrets.add(secret); }
|
|
22
|
+
|
|
23
|
+
private redact(text: string): string {
|
|
24
|
+
for (const secret of this.secrets) text = text.split(secret).join('[REDACTED]');
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
diagnostic(message: string) {
|
|
29
|
+
// eslint-disable-next-line no-control-regex -- Strip terminal control bytes from diagnostics.
|
|
30
|
+
process.stderr.write(`${this.redact(message).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')}\n`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
emit(envelope: Envelope, summary: string) {
|
|
34
|
+
// Preserve the protocol while redacting variable values and arbitrary map keys.
|
|
35
|
+
// Normalize dates first, and redact before serialization to preserve valid JSON.
|
|
36
|
+
const sanitize = (value: unknown, path = '', freeForm = false): unknown => {
|
|
37
|
+
if (typeof value === 'string') return !freeForm && protocolValues.has(path) ? value : this.redact(value);
|
|
38
|
+
if (Array.isArray(value)) return value.map(item => sanitize(item, `${path}.*`, freeForm));
|
|
39
|
+
if (value && typeof value === 'object') {
|
|
40
|
+
const entries = Object.entries(value).map(([key, item]) => ({ key, item, name: freeForm ? this.redact(key) : key }));
|
|
41
|
+
// Reserve every base name, including unchanged keys that appear later.
|
|
42
|
+
// Only renamed keys receive suffixes; unrelated field names stay intact.
|
|
43
|
+
const reserved = new Set(entries.map(entry => entry.name));
|
|
44
|
+
const used = new Set(entries.filter(entry => entry.key === entry.name).map(entry => entry.name));
|
|
45
|
+
const suffixes = new Map<string, number>();
|
|
46
|
+
return Object.fromEntries(entries.map(({ key, item, name }) => {
|
|
47
|
+
let unique = name;
|
|
48
|
+
if (key !== name && used.has(unique)) {
|
|
49
|
+
let suffix = suffixes.get(name) ?? 1;
|
|
50
|
+
do { unique = `${name}#${suffix++}`; } while (reserved.has(unique) || used.has(unique));
|
|
51
|
+
suffixes.set(name, suffix);
|
|
52
|
+
}
|
|
53
|
+
used.add(unique);
|
|
54
|
+
return [unique, sanitize(item, path ? `${path}.${key}` : key, freeForm || freeFormMaps.has(key))];
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
};
|
|
59
|
+
const serialized = JSON.stringify(sanitize(JSON.parse(JSON.stringify(envelope))), null, this.json ? undefined : 2);
|
|
60
|
+
if (this.json) process.stdout.write(`${serialized}\n`);
|
|
61
|
+
else {
|
|
62
|
+
const safe = JSON.parse(serialized) as Envelope;
|
|
63
|
+
process.stdout.write(`${this.redact(summary)}\n`);
|
|
64
|
+
if (safe.target) process.stdout.write(`${JSON.stringify(safe.target)}\n`);
|
|
65
|
+
if (safe.data !== undefined) process.stdout.write(`${JSON.stringify(safe.data, null, 2)}\n`);
|
|
66
|
+
}
|
|
67
|
+
if (envelope.error) this.diagnostic(`${envelope.error.message} ${envelope.error.hint}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { HTTPClient, LambdaDBClient, type ClientRequestOptions, type Fetcher } from '@functional-systems/lambdadb';
|
|
2
|
+
import type { Config } from './config.js';
|
|
3
|
+
|
|
4
|
+
export function runtime(config: Config) {
|
|
5
|
+
// A no-op logger still makes SDK 0.5.1 clone and drain request/response bodies.
|
|
6
|
+
// Disable its environment-controlled logger before construction instead.
|
|
7
|
+
delete process.env.LAMBDADB_DEBUG;
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const interrupt = () => controller.abort();
|
|
10
|
+
// Keep the command deadline alive across sequential SDK requests and transfers.
|
|
11
|
+
const deadline = setTimeout(() => controller.abort(new DOMException('Command deadline exceeded', 'TimeoutError')), config.timeoutMs);
|
|
12
|
+
deadline.unref();
|
|
13
|
+
process.once('SIGINT', interrupt);
|
|
14
|
+
process.once('SIGTERM', interrupt);
|
|
15
|
+
const options: ClientRequestOptions = {
|
|
16
|
+
signal: controller.signal, timeoutMs: config.timeoutMs,
|
|
17
|
+
retries: {
|
|
18
|
+
strategy: 'backoff', retryConnectionErrors: true,
|
|
19
|
+
backoff: { initialInterval: 200, maxInterval: 500, exponent: 1.5, maxElapsedTime: Math.min(2000, config.timeoutMs) },
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
// Bind the command signal at fetch dispatch as well as in SDK options, instead
|
|
23
|
+
// of relying only on the SDK's cloned Request signal chain.
|
|
24
|
+
// Keep authenticated API and unauthenticated transfer clients separate.
|
|
25
|
+
const fetcher: Fetcher = (input, init) => fetch(input, { ...init, signal: controller.signal });
|
|
26
|
+
const client = new LambdaDBClient({
|
|
27
|
+
baseUrl: config.endpoint, projectName: config.project, projectApiKey: config.apiKey,
|
|
28
|
+
timeoutMs: config.timeoutMs,
|
|
29
|
+
httpClient: new HTTPClient({ fetcher }),
|
|
30
|
+
transferClient: new HTTPClient({ fetcher }),
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
client, options,
|
|
34
|
+
close() {
|
|
35
|
+
clearTimeout(deadline);
|
|
36
|
+
process.removeListener('SIGINT', interrupt);
|
|
37
|
+
process.removeListener('SIGTERM', interrupt);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|