@manta-eu/upload 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 +21 -0
- package/README.md +72 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +58 -0
- package/dist/graphql.d.ts +10 -0
- package/dist/graphql.d.ts.map +1 -0
- package/dist/graphql.js +33 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/upload.d.ts +23 -0
- package/dist/upload.d.ts.map +1 -0
- package/dist/upload.js +109 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Manta AI AS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# @manta-eu/upload
|
|
2
|
+
|
|
3
|
+
A CLI that uploads files to a Manta datastore from outside the app: a script, a scheduled job, or
|
|
4
|
+
another system's export. It uses the upload flow the chat uses, so the file lands in the datastore
|
|
5
|
+
exactly as a chat upload would.
|
|
6
|
+
|
|
7
|
+
CSV, Parquet, Turtle and N-Triples files are then ingested into the datastore's knowledge graph.
|
|
8
|
+
Any other type is stored but not ingested.
|
|
9
|
+
|
|
10
|
+
## Use
|
|
11
|
+
|
|
12
|
+
```shell
|
|
13
|
+
pnpm manta-upload people.csv orders.parquet \
|
|
14
|
+
--api-url https://app.example \
|
|
15
|
+
--datastore salthouse \
|
|
16
|
+
--keycloak-url https://keycloak.example \
|
|
17
|
+
--keycloak-realm your-realm \
|
|
18
|
+
--client-id your-realm-app
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
For each file the CLI prints the file's IRI, its media type and its size.
|
|
22
|
+
|
|
23
|
+
## Log in
|
|
24
|
+
|
|
25
|
+
Login works as it does for `manta-codegen`, whose token code this package reuses.
|
|
26
|
+
`MANTA_CLIENT_SECRET` decides the path.
|
|
27
|
+
|
|
28
|
+
**No secret: a person.** The CLI starts the OAuth device flow with PKCE, prints a link and a code,
|
|
29
|
+
then waits for the browser. The upload is attributed to that person. The CLI keeps the refresh token,
|
|
30
|
+
so a long batch renews its access token silently; it asks for a new browser login only if Keycloak
|
|
31
|
+
refuses the refresh token, for example once the session's own lifetime ends.
|
|
32
|
+
|
|
33
|
+
**A secret: a machine.** The CLI asks for a service account token. Add `--scope` for a narrower
|
|
34
|
+
token.
|
|
35
|
+
|
|
36
|
+
```shell
|
|
37
|
+
export MANTA_CLIENT_SECRET=... # never a flag: an argument list is public on the machine
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A service account only gets a datastore today if it holds the `operator` realm role. See the
|
|
41
|
+
`manta-codegen` README for that trap and for the `azp` one.
|
|
42
|
+
|
|
43
|
+
## What the CLI does
|
|
44
|
+
|
|
45
|
+
- **Integrity.** The CLI hashes the bytes it uploads and aborts if the file changes while it's being
|
|
46
|
+
read. A part that storage answers with a 5xx, a 429 or a 408, or whose connection drops, is retried
|
|
47
|
+
for up to three attempts with backoff; a refusal such as a 403 is not.
|
|
48
|
+
|
|
49
|
+
## What the API checks
|
|
50
|
+
|
|
51
|
+
- **Access.** The API refuses the upload if the token may not use `--datastore`. It never falls back
|
|
52
|
+
to another datastore.
|
|
53
|
+
- **Content.** For the four ingested types, the API reads the start of the file, and for Parquet the
|
|
54
|
+
end too. It refuses and deletes a file whose bytes contradict its extension: a `.parquet` without
|
|
55
|
+
Parquet's magic bytes, or a `.csv`, `.ttl` or `.nt` that isn't UTF-8 text. Re-save a CSV exported as
|
|
56
|
+
Windows-1252 or ISO-8859-1 as UTF-8 first.
|
|
57
|
+
- **A check that can't run.** If the API cannot read the file back to check it (a storage error, say),
|
|
58
|
+
it records the file anyway and leaves the verdict to the ingest.
|
|
59
|
+
- **Size.** The API accepts files up to 5 GiB. The ingest skips a file over its own limit, 512 MiB by
|
|
60
|
+
default.
|
|
61
|
+
|
|
62
|
+
## How ingesting went
|
|
63
|
+
|
|
64
|
+
The ingest runs in batches, a few minutes after upload. Each version of a file it handles gets a
|
|
65
|
+
**File Ingest** (`ingest:IngestRun`) node in the datastore:
|
|
66
|
+
|
|
67
|
+
- `prov:used` points to the file;
|
|
68
|
+
- `ingest:status` is `ingested`, `unparsable`, `gone` or `too_large`, with `prov:endedAtTime`;
|
|
69
|
+
- on success, `ingest:recordsRead`, `ingest:recordsDropped` and `prov:generated`, the dataset the
|
|
70
|
+
rows belong to.
|
|
71
|
+
|
|
72
|
+
A file whose key can't be written as an IRI gets no node. Agents never mints such a key.
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cachedToken, describeError, deviceLoginTokenSource, mintWithClientCredentials, } from "@manta-eu/codegen";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { graphqlClient, graphqlEndpoint } from "./graphql.js";
|
|
5
|
+
import { uploadFile } from "./upload.js";
|
|
6
|
+
// The secret stays out of the flags: an argument list is readable to every process on the machine.
|
|
7
|
+
function tokenSourceFor(options) {
|
|
8
|
+
const client = { baseUrl: options.keycloakUrl, realm: options.keycloakRealm, clientId: options.clientId };
|
|
9
|
+
const secret = process.env.MANTA_CLIENT_SECRET;
|
|
10
|
+
if (secret) {
|
|
11
|
+
return cachedToken(() => mintWithClientCredentials({ ...client, clientSecret: secret, scope: options.scope }));
|
|
12
|
+
}
|
|
13
|
+
// Refreshes silently, so a long batch does not stop for a second browser login.
|
|
14
|
+
return cachedToken(deviceLoginTokenSource(client, (login) => console.error(`manta-upload: open ${login.verificationUri} and enter the code ${login.userCode}`)));
|
|
15
|
+
}
|
|
16
|
+
async function upload(paths, options) {
|
|
17
|
+
const graphql = graphqlClient({
|
|
18
|
+
endpoint: graphqlEndpoint(options.apiUrl),
|
|
19
|
+
datastore: options.datastore,
|
|
20
|
+
token: tokenSourceFor(options),
|
|
21
|
+
});
|
|
22
|
+
let failed = 0;
|
|
23
|
+
// A failed file does not stop the rest of the batch; the exit code reports it at the end.
|
|
24
|
+
for (const path of paths) {
|
|
25
|
+
try {
|
|
26
|
+
const uploaded = await uploadFile({ path, graphql });
|
|
27
|
+
console.error(`manta-upload: ${path} -> ${uploaded.fileIri} (${uploaded.mediaType}, ${uploaded.byteSize} bytes)`);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
failed += 1;
|
|
31
|
+
console.error(`manta-upload: ${path} failed: ${describeError(error)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (failed > 0) {
|
|
35
|
+
console.error(`manta-upload: ${failed} of ${paths.length} file(s) failed`);
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const program = new Command()
|
|
40
|
+
.name("manta-upload")
|
|
41
|
+
.description("Upload files to a Manta datastore. CSV, Parquet, Turtle and N-Triples files are ingested into its knowledge graph.")
|
|
42
|
+
.argument("<files...>", "files to upload")
|
|
43
|
+
.requiredOption("--api-url <url>", "Manta API base URL")
|
|
44
|
+
.requiredOption("--datastore <id>", "datastore to upload into")
|
|
45
|
+
.requiredOption("--keycloak-url <url>", "Keycloak base URL")
|
|
46
|
+
.requiredOption("--keycloak-realm <realm>", "Keycloak realm")
|
|
47
|
+
.requiredOption("--client-id <id>", "Keycloak client")
|
|
48
|
+
.option("--scope <scope>", "scope to ask a service account token for")
|
|
49
|
+
.action(async (paths, options) => {
|
|
50
|
+
try {
|
|
51
|
+
await upload(paths, options);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
console.error(describeError(error));
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
await program.parseAsync();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type FetchLike } from "@manta-eu/codegen";
|
|
2
|
+
export declare function graphqlEndpoint(apiUrl: string): string;
|
|
3
|
+
export type GraphqlClient = <T>(query: string, variables: Record<string, unknown>) => Promise<T>;
|
|
4
|
+
export declare function graphqlClient(options: {
|
|
5
|
+
endpoint: string;
|
|
6
|
+
datastore: string;
|
|
7
|
+
token: () => Promise<string>;
|
|
8
|
+
fetchImpl?: FetchLike;
|
|
9
|
+
}): GraphqlClient;
|
|
10
|
+
//# sourceMappingURL=graphql.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"graphql.d.ts","sourceRoot":"","sources":["../src/graphql.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,SAAS,EAAY,MAAM,mBAAmB,CAAC;AAE5E,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;AAGjG,wBAAgB,aAAa,CAAC,OAAO,EAAE;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7B,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB,GAAG,aAAa,CAqChB"}
|
package/dist/graphql.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describeError, readJson } from "@manta-eu/codegen";
|
|
2
|
+
export function graphqlEndpoint(apiUrl) {
|
|
3
|
+
return `${apiUrl.replace(/\/$/, "")}/graphql`;
|
|
4
|
+
}
|
|
5
|
+
// The datastore travels as a header: the API refuses the call rather than fall back to another one.
|
|
6
|
+
export function graphqlClient(options) {
|
|
7
|
+
const { endpoint, datastore, token, fetchImpl = fetch } = options;
|
|
8
|
+
return async (query, variables) => {
|
|
9
|
+
// Minted outside the try: a login that fails is not the API being unreachable.
|
|
10
|
+
const authorization = `Bearer ${await token()}`;
|
|
11
|
+
let response;
|
|
12
|
+
try {
|
|
13
|
+
response = await fetchImpl(endpoint, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: { "content-type": "application/json", authorization, "x-manta-datastore": datastore },
|
|
16
|
+
body: JSON.stringify({ query, variables }),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
catch (cause) {
|
|
20
|
+
throw new Error(`manta-upload: cannot reach ${endpoint}. ${describeError(cause)}`, { cause });
|
|
21
|
+
}
|
|
22
|
+
const body = await readJson(response, endpoint, "manta-upload");
|
|
23
|
+
if (body.errors?.length) {
|
|
24
|
+
throw new Error(`manta-upload: ${body.errors.map((error) => error.message).join("; ")}`);
|
|
25
|
+
}
|
|
26
|
+
if (!response.ok || !body.data) {
|
|
27
|
+
// The Worker's auth middleware answers before Yoga, so its reason is `error`, not `errors`.
|
|
28
|
+
const reason = typeof body.error === "string" ? `: ${body.error}` : " without data";
|
|
29
|
+
throw new Error(`manta-upload: ${endpoint} answered status ${response.status}${reason}`);
|
|
30
|
+
}
|
|
31
|
+
return body.data;
|
|
32
|
+
};
|
|
33
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAClF,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,cAAc,EAAE,KAAK,UAAU,EAAE,KAAK,KAAK,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
package/dist/upload.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type FetchLike } from "@manta-eu/codegen";
|
|
2
|
+
import type { GraphqlClient } from "./graphql.js";
|
|
3
|
+
export declare const uploadPartSize: number;
|
|
4
|
+
export type UploadedFile = {
|
|
5
|
+
fileIri: string;
|
|
6
|
+
mediaType: string;
|
|
7
|
+
storageKey: string;
|
|
8
|
+
byteSize: number;
|
|
9
|
+
};
|
|
10
|
+
export type PartReader = {
|
|
11
|
+
read(buffer: Uint8Array, offset: number, length: number, position: number): Promise<{
|
|
12
|
+
bytesRead: number;
|
|
13
|
+
}>;
|
|
14
|
+
};
|
|
15
|
+
export declare function readFully(file: PartReader, start: number, length: number): Promise<Uint8Array>;
|
|
16
|
+
export type Sleep = (ms: number) => Promise<void>;
|
|
17
|
+
export declare function uploadFile(options: {
|
|
18
|
+
path: string;
|
|
19
|
+
graphql: GraphqlClient;
|
|
20
|
+
fetchImpl?: FetchLike;
|
|
21
|
+
sleep?: Sleep;
|
|
22
|
+
}): Promise<UploadedFile>;
|
|
23
|
+
//# sourceMappingURL=upload.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,SAAS,EAAqB,MAAM,mBAAmB,CAAC;AACtE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAGlD,eAAO,MAAM,cAAc,QAAkB,CAAC;AAoB9C,MAAM,MAAM,YAAY,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAExG,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC5G,CAAC;AAGF,wBAAsB,SAAS,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAapG;AAED,MAAM,MAAM,KAAK,GAAG,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAiDlD,wBAAsB,UAAU,CAAC,OAAO,EAAE;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,aAAa,CAAC;IACvB,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,KAAK,CAAC,EAAE,KAAK,CAAC;CACf,GAAG,OAAO,CAAC,YAAY,CAAC,CASxB"}
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { isTransientStatus } from "@manta-eu/codegen";
|
|
5
|
+
// Must equal agents' uploadPartSize: the API presigns one part per this many bytes.
|
|
6
|
+
export const uploadPartSize = 8 * 1024 * 1024;
|
|
7
|
+
const initiateMutation = `mutation Initiate($input: InitiateUploadInput!) {
|
|
8
|
+
initiateUpload(input: $input) { uploadId storageKey partUrls { partNumber url } }
|
|
9
|
+
}`;
|
|
10
|
+
const completeMutation = `mutation Complete($input: CompleteUploadInput!) {
|
|
11
|
+
completeUpload(input: $input) { fileIri mediaType }
|
|
12
|
+
}`;
|
|
13
|
+
const abortMutation = `mutation Abort($input: AbortUploadInput!) {
|
|
14
|
+
abortUpload(input: $input) { ok }
|
|
15
|
+
}`;
|
|
16
|
+
// One read may return fewer bytes than asked for, so read until the part is full or the file ends.
|
|
17
|
+
export async function readFully(file, start, length) {
|
|
18
|
+
const buffer = new Uint8Array(length);
|
|
19
|
+
let filled = 0;
|
|
20
|
+
while (filled < length) {
|
|
21
|
+
const { bytesRead } = await file.read(buffer, filled, length - filled, start + filled);
|
|
22
|
+
if (bytesRead === 0)
|
|
23
|
+
break;
|
|
24
|
+
filled += bytesRead;
|
|
25
|
+
}
|
|
26
|
+
return buffer.subarray(0, filled);
|
|
27
|
+
}
|
|
28
|
+
const partAttempts = 3;
|
|
29
|
+
const partBackoffMs = 1_000;
|
|
30
|
+
const sleepFor = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
31
|
+
// Only what storage has finally refused, so an unrecognized failure stays retryable.
|
|
32
|
+
class NonRetryablePartError extends Error {
|
|
33
|
+
}
|
|
34
|
+
// Overload, a timeout or a dropped connection is transient for S3-compatible stores: the part is retried, not the file.
|
|
35
|
+
async function putPart(url, body, fetchImpl, sleep) {
|
|
36
|
+
for (let attempt = 1;; attempt++) {
|
|
37
|
+
try {
|
|
38
|
+
return await putPartOnce(url, body, fetchImpl);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const retryable = !(error instanceof NonRetryablePartError);
|
|
42
|
+
if (!retryable || attempt >= partAttempts)
|
|
43
|
+
throw error;
|
|
44
|
+
await sleep(partBackoffMs * 2 ** (attempt - 1));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function putPartOnce(url, body, fetchImpl) {
|
|
49
|
+
const response = await fetchImpl(url, { method: "PUT", body });
|
|
50
|
+
const etag = response.headers.get("etag");
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
// The storage error document is the only thing that says why a part was refused.
|
|
53
|
+
const detail = (await response.text().catch(() => "")).slice(0, 500);
|
|
54
|
+
const message = `manta-upload: storage refused a part with status ${response.status}: ${detail}`;
|
|
55
|
+
throw isTransientStatus(response.status) ? new Error(message) : new NonRetryablePartError(message);
|
|
56
|
+
}
|
|
57
|
+
// The part is already stored, so a body that cannot be discarded must not cost a re-PUT.
|
|
58
|
+
await response.body?.cancel().catch(() => undefined);
|
|
59
|
+
// The part is stored, so repeating the PUT cannot conjure the header completeUpload needs.
|
|
60
|
+
if (!etag) {
|
|
61
|
+
throw new NonRetryablePartError("manta-upload: storage stored a part but answered without an ETag");
|
|
62
|
+
}
|
|
63
|
+
return etag;
|
|
64
|
+
}
|
|
65
|
+
// Parts go up one at a time, so an upload holds at most one part in memory.
|
|
66
|
+
export async function uploadFile(options) {
|
|
67
|
+
// One handle for the whole upload: an export that lands by rename cannot swap the file under the loop.
|
|
68
|
+
const file = await open(options.path, "r");
|
|
69
|
+
try {
|
|
70
|
+
return await uploadHandle(file, { ...options, fileName: basename(options.path) });
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
await file.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function uploadHandle(file, options) {
|
|
77
|
+
const { path, fileName, graphql, fetchImpl = fetch, sleep = sleepFor } = options;
|
|
78
|
+
const byteSize = (await file.stat()).size;
|
|
79
|
+
if (byteSize === 0)
|
|
80
|
+
throw new Error(`manta-upload: ${path} is empty`);
|
|
81
|
+
const { initiateUpload } = await graphql(initiateMutation, { input: { fileName, byteSize } });
|
|
82
|
+
const { uploadId, storageKey, partUrls } = initiateUpload;
|
|
83
|
+
try {
|
|
84
|
+
if (partUrls.length !== Math.ceil(byteSize / uploadPartSize)) {
|
|
85
|
+
throw new Error(`manta-upload: the API split ${fileName} into ${partUrls.length} parts, which this client cannot fill`);
|
|
86
|
+
}
|
|
87
|
+
const parts = [];
|
|
88
|
+
// Hashed from the bytes that went up, so the hash describes the stored object.
|
|
89
|
+
const hash = createHash("sha256");
|
|
90
|
+
for (const { partNumber, url } of partUrls) {
|
|
91
|
+
const start = (partNumber - 1) * uploadPartSize;
|
|
92
|
+
const body = await readFully(file, start, uploadPartSize);
|
|
93
|
+
if (body.length !== Math.min(uploadPartSize, byteSize - start)) {
|
|
94
|
+
throw new Error(`manta-upload: ${fileName} changed while it was being read`);
|
|
95
|
+
}
|
|
96
|
+
hash.update(body);
|
|
97
|
+
parts.push({ partNumber, etag: await putPart(url, body, fetchImpl, sleep) });
|
|
98
|
+
}
|
|
99
|
+
const facts = { byteSize, sha256: hash.digest("hex") };
|
|
100
|
+
const { completeUpload } = await graphql(completeMutation, {
|
|
101
|
+
input: { uploadId, storageKey, fileName, parts, facts },
|
|
102
|
+
});
|
|
103
|
+
return { ...completeUpload, storageKey, byteSize };
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
await graphql(abortMutation, { input: { uploadId, storageKey } }).catch(() => undefined);
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@manta-eu/upload",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI that uploads files to a Manta datastore, where supported types are ingested into its knowledge graph",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"manta-upload": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"author": "",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"commander": "^15.0.0",
|
|
19
|
+
"@manta-eu/codegen": "0.1.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^24.13.3",
|
|
23
|
+
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
|
24
|
+
"@typescript-eslint/parser": "^8.56.1",
|
|
25
|
+
"eslint": "^9.39.2",
|
|
26
|
+
"tsx": "^4.20.6",
|
|
27
|
+
"typescript": "^5.0.0",
|
|
28
|
+
"vitest": "^3.0.0"
|
|
29
|
+
},
|
|
30
|
+
"main": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"test": "vitest --run",
|
|
41
|
+
"types": "tsc --noEmit"
|
|
42
|
+
}
|
|
43
|
+
}
|