@labelgrid/core 0.1.0 → 0.2.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 +49 -0
- package/dist/api/http.d.ts +23 -0
- package/dist/api/http.js +84 -5
- package/dist/api/upload.d.ts +50 -1
- package/dist/api/upload.js +123 -51
- package/dist/entities.d.ts +3 -1
- package/dist/entities.js +11 -9
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/timeouts.d.ts +14 -0
- package/dist/timeouts.js +15 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
`@labelgrid/core` is primarily an internal shared client for the LabelGrid MCP
|
|
9
9
|
server and CLI — there are no API-stability promises before 1.0.
|
|
10
10
|
|
|
11
|
+
## [0.2.1] - 2026-08-05
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- The catalog entity descriptions carried by this package were tightened. The
|
|
16
|
+
`RELEASE_LOCKED_FIELDS` caveat now lives only on the tool it applies to
|
|
17
|
+
instead of being repeated for every entity. Field requirements, paths and
|
|
18
|
+
exported types are unchanged.
|
|
19
|
+
|
|
20
|
+
## [0.2.0] - 2026-07-23
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `MAX_UPLOAD_BYTES` (4 GiB) upload ceiling and a `FILE_TOO_LARGE` structured
|
|
25
|
+
error. An oversized file is now rejected with an honest size-and-limit
|
|
26
|
+
message instead of being mislabeled `FILE_NOT_FOUND`.
|
|
27
|
+
- `mintUpload`, `putToPresignedUrl` and `commitUpload` — the presigned upload
|
|
28
|
+
flow's three steps are now individually exported and composed by
|
|
29
|
+
`uploadViaPresignedUrl`, so an alternate transport can reuse mint + commit and
|
|
30
|
+
swap only the byte-transfer step. Pure refactor; no behavior change.
|
|
31
|
+
- `LabelGridClient.getRaw(path, query?)` — an authenticated raw GET for file
|
|
32
|
+
downloads that returns the live response for streaming, or the same
|
|
33
|
+
normalized structured error as the JSON path, honoring the raw transfer
|
|
34
|
+
timeout. Consolidates the duplicated authed-download helpers that lived in the
|
|
35
|
+
MCP and CLI packages.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- Presigned-URL uploads now stream the file from disk with an explicit
|
|
40
|
+
`Content-Length` header instead of buffering the whole file into memory, so
|
|
41
|
+
peak memory stays flat regardless of file size. (An explicit `Content-Length`
|
|
42
|
+
is required — S3-compatible presigned PUTs reject a chunked body with 411.)
|
|
43
|
+
- Multipart uploads no longer make a redundant in-memory copy of the file bytes
|
|
44
|
+
before building the form Blob.
|
|
45
|
+
- `UploadOptions` accepts an optional `onProgress` callback, invoked with the
|
|
46
|
+
running byte count as the presigned PUT streams (for a transfer-progress UI).
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- Multipart uploads (cover art, license documents) now use the longer raw
|
|
51
|
+
transfer timeout instead of the 60s JSON request timeout, so a large file on
|
|
52
|
+
a slow uplink is no longer aborted mid-upload.
|
|
53
|
+
- `raw()` now composes a caller-supplied `AbortSignal` with the transfer-timeout
|
|
54
|
+
signal (`AbortSignal.any`) instead of letting the caller's signal silently
|
|
55
|
+
replace and disable the timeout.
|
|
56
|
+
- Presigned-upload progress reporting now forwards a source-stream error onto
|
|
57
|
+
the composed request body, so a file that becomes unreadable mid-transfer
|
|
58
|
+
fails cleanly as `UPLOAD_FAILED` instead of raising an unhandled stream error.
|
|
59
|
+
|
|
11
60
|
## [0.1.0] - 2026-07-20
|
|
12
61
|
|
|
13
62
|
### Added
|
package/dist/api/http.d.ts
CHANGED
|
@@ -23,6 +23,17 @@ export type ApiResult<T = unknown> = {
|
|
|
23
23
|
} | {
|
|
24
24
|
error: ApiError;
|
|
25
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* The outcome of a raw authenticated GET: either the live {@link Response} (for
|
|
28
|
+
* the caller to stream a file body from) or a normalized structured error.
|
|
29
|
+
*/
|
|
30
|
+
export type RawResult = {
|
|
31
|
+
ok: true;
|
|
32
|
+
res: Response;
|
|
33
|
+
} | {
|
|
34
|
+
ok: false;
|
|
35
|
+
error: ApiError;
|
|
36
|
+
};
|
|
26
37
|
export declare class LabelGridClient {
|
|
27
38
|
private readonly baseUrl;
|
|
28
39
|
private readonly token;
|
|
@@ -74,4 +85,16 @@ export declare class LabelGridClient {
|
|
|
74
85
|
* Bearer token would break the signature.
|
|
75
86
|
*/
|
|
76
87
|
raw(url: string, init: RequestInit): Promise<Response>;
|
|
88
|
+
/**
|
|
89
|
+
* Authenticated raw GET for file-body downloads (statement CSV/PDF): sends the
|
|
90
|
+
* same auth headers as the JSON path but returns the live {@link Response} on
|
|
91
|
+
* success so the caller streams the body to disk, never buffering a large file
|
|
92
|
+
* in memory. A non-2xx is read and normalized to the same structured
|
|
93
|
+
* {@link ApiError} as the JSON path; a network/timeout failure maps to the same
|
|
94
|
+
* NETWORK_ERROR/TIMEOUT. Uses the longer raw transfer timeout, since a download
|
|
95
|
+
* is a byte transfer, not a JSON call.
|
|
96
|
+
*/
|
|
97
|
+
getRaw(path: string, query?: Record<string, unknown>): Promise<RawResult>;
|
|
98
|
+
/** Maps a fetch rejection (abort/timeout vs other) to a structured error. */
|
|
99
|
+
private mapFetchError;
|
|
77
100
|
}
|
package/dist/api/http.js
CHANGED
|
@@ -193,7 +193,11 @@ export class LabelGridClient {
|
|
|
193
193
|
// across separate tool calls); otherwise a fresh UUID is generated.
|
|
194
194
|
headers['Idempotency-Key'] = opts.idempotencyKey ?? randomUUID();
|
|
195
195
|
}
|
|
196
|
-
|
|
196
|
+
// A raw body is a byte transfer (a multipart upload), not a JSON call — it
|
|
197
|
+
// gets the longer transfer timeout so a large file on a slow uplink is not
|
|
198
|
+
// aborted at the 60s JSON deadline.
|
|
199
|
+
const effectiveTimeoutMs = opts.rawBody !== undefined ? this.rawTimeoutMs : this.timeoutMs;
|
|
200
|
+
const init = { method, headers, signal: AbortSignal.timeout(effectiveTimeoutMs) };
|
|
197
201
|
if (opts.rawBody !== undefined) {
|
|
198
202
|
init.body = opts.rawBody;
|
|
199
203
|
}
|
|
@@ -211,7 +215,7 @@ export class LabelGridClient {
|
|
|
211
215
|
return {
|
|
212
216
|
error: {
|
|
213
217
|
code: 'TIMEOUT',
|
|
214
|
-
message: `The request timed out after ${Math.round(
|
|
218
|
+
message: `The request timed out after ${Math.round(effectiveTimeoutMs / 1000)} seconds. Try again, or narrow the request.`,
|
|
215
219
|
status: 0,
|
|
216
220
|
},
|
|
217
221
|
};
|
|
@@ -257,7 +261,7 @@ export class LabelGridClient {
|
|
|
257
261
|
return {
|
|
258
262
|
error: {
|
|
259
263
|
code: 'TIMEOUT',
|
|
260
|
-
message: `The request timed out after ${Math.round(
|
|
264
|
+
message: `The request timed out after ${Math.round(effectiveTimeoutMs / 1000)} seconds while reading the response. Try again, or narrow the request.`,
|
|
261
265
|
status: 0,
|
|
262
266
|
},
|
|
263
267
|
};
|
|
@@ -376,7 +380,20 @@ export class LabelGridClient {
|
|
|
376
380
|
};
|
|
377
381
|
}
|
|
378
382
|
const form = new FormData();
|
|
379
|
-
|
|
383
|
+
// Wrap the buffer's bytes in a zero-copy Uint8Array VIEW (same underlying
|
|
384
|
+
// memory, honoring byteOffset/byteLength on a pooled Buffer), which the Blob
|
|
385
|
+
// then copies once. The previous `new Blob([new Uint8Array(bytes)])` made an
|
|
386
|
+
// extra full copy before the Blob's own copy — that redundant copy is gone.
|
|
387
|
+
// Node's FormData accepts a Blob field. Streaming multipart is not practical
|
|
388
|
+
// with undici's FormData (it materializes the parts), so this stays a single
|
|
389
|
+
// in-memory buffer — acceptable for the small files (images, PDFs, lyrics)
|
|
390
|
+
// that use the multipart path; the large binaries go through the streaming
|
|
391
|
+
// presigned-URL flow instead.
|
|
392
|
+
// The `as BlobPart` cast is only because the DOM lib types a Buffer's
|
|
393
|
+
// ArrayBufferLike (which could be a SharedArrayBuffer) too narrowly for
|
|
394
|
+
// BlobPart; the view is a valid Blob part at runtime.
|
|
395
|
+
const view = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
396
|
+
form.append(fieldName, new Blob([view], { type: contentType(filePath) }), basename(filePath));
|
|
380
397
|
for (const [key, value] of Object.entries(extra ?? {})) {
|
|
381
398
|
form.append(key, value);
|
|
382
399
|
}
|
|
@@ -389,6 +406,68 @@ export class LabelGridClient {
|
|
|
389
406
|
* Bearer token would break the signature.
|
|
390
407
|
*/
|
|
391
408
|
raw(url, init) {
|
|
392
|
-
|
|
409
|
+
const timeoutSignal = AbortSignal.timeout(this.rawTimeoutMs);
|
|
410
|
+
// Compose, never replace: spreading `...init` last would let a caller-supplied
|
|
411
|
+
// signal silently drop the transfer timeout. AbortSignal.any aborts when
|
|
412
|
+
// EITHER the caller's signal or the timeout fires, so the timeout always holds.
|
|
413
|
+
const signal = init.signal != null ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
|
|
414
|
+
return this.fetchFn(url, { ...init, signal });
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Authenticated raw GET for file-body downloads (statement CSV/PDF): sends the
|
|
418
|
+
* same auth headers as the JSON path but returns the live {@link Response} on
|
|
419
|
+
* success so the caller streams the body to disk, never buffering a large file
|
|
420
|
+
* in memory. A non-2xx is read and normalized to the same structured
|
|
421
|
+
* {@link ApiError} as the JSON path; a network/timeout failure maps to the same
|
|
422
|
+
* NETWORK_ERROR/TIMEOUT. Uses the longer raw transfer timeout, since a download
|
|
423
|
+
* is a byte transfer, not a JSON call.
|
|
424
|
+
*/
|
|
425
|
+
async getRaw(path, query) {
|
|
426
|
+
const url = `${this.baseUrl}${path}${buildQuery(query)}`;
|
|
427
|
+
let res;
|
|
428
|
+
try {
|
|
429
|
+
res = await this.fetchFn(url, {
|
|
430
|
+
method: 'GET',
|
|
431
|
+
headers: this.authHeaders(),
|
|
432
|
+
signal: AbortSignal.timeout(this.rawTimeoutMs),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
return { ok: false, error: this.mapFetchError(err) };
|
|
437
|
+
}
|
|
438
|
+
if (res.ok)
|
|
439
|
+
return { ok: true, res };
|
|
440
|
+
// Read and normalize the error body exactly as the JSON path does.
|
|
441
|
+
let body = null;
|
|
442
|
+
try {
|
|
443
|
+
const text = await res.text();
|
|
444
|
+
if (text.length > 0) {
|
|
445
|
+
try {
|
|
446
|
+
body = JSON.parse(text);
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
body = text;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// no readable error body — normalizeError falls back to status defaults
|
|
455
|
+
}
|
|
456
|
+
return { ok: false, error: normalizeError(res, body) };
|
|
457
|
+
}
|
|
458
|
+
/** Maps a fetch rejection (abort/timeout vs other) to a structured error. */
|
|
459
|
+
mapFetchError(err) {
|
|
460
|
+
if (err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
461
|
+
return {
|
|
462
|
+
code: 'TIMEOUT',
|
|
463
|
+
message: `The request timed out after ${Math.round(this.rawTimeoutMs / 1000)} seconds. Try again, or narrow the request.`,
|
|
464
|
+
status: 0,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
code: 'NETWORK_ERROR',
|
|
469
|
+
message: err instanceof Error ? err.message : 'Network request failed.',
|
|
470
|
+
status: 0,
|
|
471
|
+
};
|
|
393
472
|
}
|
|
394
473
|
}
|
package/dist/api/upload.d.ts
CHANGED
|
@@ -13,8 +13,26 @@
|
|
|
13
13
|
*
|
|
14
14
|
* A failure at step 2 aborts before the commit, so a half-uploaded object is
|
|
15
15
|
* never finalized. Business rules (format checks, transcoding) stay server-side.
|
|
16
|
+
*
|
|
17
|
+
* The step-2 PUT streams the file from disk rather than buffering it whole: the
|
|
18
|
+
* request body is a read stream and the object's byte size is sent as an
|
|
19
|
+
* explicit Content-Length. (An S3-compatible presigned PUT rejects a chunked
|
|
20
|
+
* Transfer-Encoding body with 411 MissingContentLength, and fetch defaults a
|
|
21
|
+
* stream body to chunked, so the header is mandatory.) This keeps peak memory
|
|
22
|
+
* flat regardless of file size.
|
|
23
|
+
*/
|
|
24
|
+
import type { ApiError, ApiResult, LabelGridClient } from './http.js';
|
|
25
|
+
/** Hard ceiling on a single uploaded file, in bytes (4 GiB). */
|
|
26
|
+
export declare const MAX_UPLOAD_BYTES: number;
|
|
27
|
+
/**
|
|
28
|
+
* Composes a byte-counting Transform onto `src` for progress reporting and —
|
|
29
|
+
* critically — forwards a SOURCE error onto the composed body. `pipe()` does NOT
|
|
30
|
+
* propagate a source-stream error to its destination, so without this a file
|
|
31
|
+
* that becomes unreadable mid-transfer (deleted, an I/O fault) would emit an
|
|
32
|
+
* unhandled 'error' on the read stream — crashing the process — instead of
|
|
33
|
+
* destroying the composed body so `fetch` rejects into the UPLOAD_FAILED catch.
|
|
16
34
|
*/
|
|
17
|
-
|
|
35
|
+
export declare function attachProgressCounter(src: NodeJS.ReadableStream, onProgress: (bytesSoFar: number) => void): NodeJS.ReadableStream;
|
|
18
36
|
/**
|
|
19
37
|
* The structural subset of {@link LabelGridClient} the presigned-upload flow
|
|
20
38
|
* needs. Declared as a Pick so any object with these methods (including a test
|
|
@@ -28,5 +46,36 @@ export type UploadOptions = {
|
|
|
28
46
|
commitPath: string;
|
|
29
47
|
/** Absolute or relative local path to the file to upload. */
|
|
30
48
|
filePath: string;
|
|
49
|
+
/** Byte ceiling override (defaults to {@link MAX_UPLOAD_BYTES}); for tests. */
|
|
50
|
+
maxBytes?: number;
|
|
51
|
+
/** Called with the running byte count as the upload streams (for a progress UI). */
|
|
52
|
+
onProgress?: (bytesSoFar: number) => void;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* THE SEAM: the presigned flow is three independently-callable steps —
|
|
56
|
+
* {@link mintUpload} (get a signed URL + key), {@link putToPresignedUrl} (send
|
|
57
|
+
* the bytes), {@link commitUpload} (record the object) — and
|
|
58
|
+
* {@link uploadViaPresignedUrl} just composes them. An alternate transport that
|
|
59
|
+
* does the PUT out of process (e.g. a browser or a worker doing the byte
|
|
60
|
+
* transfer directly) can reuse mint + commit and swap only the middle step,
|
|
61
|
+
* without reimplementing the URL-minting or commit contracts.
|
|
62
|
+
*/
|
|
63
|
+
/** The result of minting a presigned URL: the signed URL + object key, or an error. */
|
|
64
|
+
export type MintResult = {
|
|
65
|
+
uploadUrl: string;
|
|
66
|
+
key: string;
|
|
67
|
+
} | {
|
|
68
|
+
error: ApiError;
|
|
31
69
|
};
|
|
70
|
+
/** Step 1: mint the presigned URL + object key for `filename`. */
|
|
71
|
+
export declare function mintUpload(client: Pick<UploadHttp, 'post'>, uploadUrlPath: string, filename: string): Promise<MintResult>;
|
|
72
|
+
/**
|
|
73
|
+
* Step 2: stream the file's bytes to the presigned URL — NO auth header (the URL
|
|
74
|
+
* is signed). Re-stats the file so Content-Length is its size at upload time,
|
|
75
|
+
* enforcing the byte ceiling and the FILE_NOT_FOUND (TOCTOU) contract there.
|
|
76
|
+
* Returns null on success, or a structured error.
|
|
77
|
+
*/
|
|
78
|
+
export declare function putToPresignedUrl(client: Pick<UploadHttp, 'raw'>, uploadUrl: string, filePath: string, maxBytes?: number, onProgress?: (bytesSoFar: number) => void): Promise<ApiError | null>;
|
|
79
|
+
/** Step 3: commit the uploaded object key (idempotent — a retried commit will not duplicate). */
|
|
80
|
+
export declare function commitUpload(client: Pick<UploadHttp, 'put'>, commitPath: string, key: string): Promise<ApiResult<unknown>>;
|
|
32
81
|
export declare function uploadViaPresignedUrl(client: UploadHttp, opts: UploadOptions): Promise<ApiResult<unknown>>;
|
package/dist/api/upload.js
CHANGED
|
@@ -13,69 +13,118 @@
|
|
|
13
13
|
*
|
|
14
14
|
* A failure at step 2 aborts before the commit, so a half-uploaded object is
|
|
15
15
|
* never finalized. Business rules (format checks, transcoding) stay server-side.
|
|
16
|
+
*
|
|
17
|
+
* The step-2 PUT streams the file from disk rather than buffering it whole: the
|
|
18
|
+
* request body is a read stream and the object's byte size is sent as an
|
|
19
|
+
* explicit Content-Length. (An S3-compatible presigned PUT rejects a chunked
|
|
20
|
+
* Transfer-Encoding body with 411 MissingContentLength, and fetch defaults a
|
|
21
|
+
* stream body to chunked, so the header is mandatory.) This keeps peak memory
|
|
22
|
+
* flat regardless of file size.
|
|
16
23
|
*/
|
|
17
|
-
import { statSync } from 'node:fs';
|
|
18
|
-
import { readFile } from 'node:fs/promises';
|
|
24
|
+
import { createReadStream, statSync } from 'node:fs';
|
|
19
25
|
import { basename } from 'node:path';
|
|
26
|
+
import { Transform } from 'node:stream';
|
|
20
27
|
import { log } from '../log.js';
|
|
21
28
|
import { contentType } from './content-types.js';
|
|
22
|
-
/**
|
|
23
|
-
|
|
29
|
+
/** Hard ceiling on a single uploaded file, in bytes (4 GiB). */
|
|
30
|
+
export const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024;
|
|
31
|
+
/** Stats a path, returning its size only for an existing regular file. */
|
|
32
|
+
function statReadableFile(p) {
|
|
24
33
|
try {
|
|
25
|
-
|
|
34
|
+
const st = statSync(p);
|
|
35
|
+
return st.isFile() ? { size: st.size } : null;
|
|
26
36
|
}
|
|
27
37
|
catch {
|
|
28
|
-
return
|
|
38
|
+
return null;
|
|
29
39
|
}
|
|
30
40
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Composes a byte-counting Transform onto `src` for progress reporting and —
|
|
43
|
+
* critically — forwards a SOURCE error onto the composed body. `pipe()` does NOT
|
|
44
|
+
* propagate a source-stream error to its destination, so without this a file
|
|
45
|
+
* that becomes unreadable mid-transfer (deleted, an I/O fault) would emit an
|
|
46
|
+
* unhandled 'error' on the read stream — crashing the process — instead of
|
|
47
|
+
* destroying the composed body so `fetch` rejects into the UPLOAD_FAILED catch.
|
|
48
|
+
*/
|
|
49
|
+
export function attachProgressCounter(src, onProgress) {
|
|
50
|
+
let transferred = 0;
|
|
51
|
+
const counter = new Transform({
|
|
52
|
+
transform(chunk, _enc, cb) {
|
|
53
|
+
transferred += chunk.length;
|
|
54
|
+
onProgress(transferred);
|
|
55
|
+
cb(null, chunk);
|
|
56
|
+
},
|
|
44
57
|
});
|
|
58
|
+
src.pipe(counter);
|
|
59
|
+
src.on('error', (e) => counter.destroy(e instanceof Error ? e : new Error(String(e))));
|
|
60
|
+
return counter;
|
|
61
|
+
}
|
|
62
|
+
function fileTooLargeError(size, limit) {
|
|
63
|
+
return {
|
|
64
|
+
code: 'FILE_TOO_LARGE',
|
|
65
|
+
message: `The file is ${size} bytes, over the ${limit}-byte upload limit.`,
|
|
66
|
+
status: 0,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Step 1: mint the presigned URL + object key for `filename`. */
|
|
70
|
+
export async function mintUpload(client, uploadUrlPath, filename) {
|
|
71
|
+
const minted = await client.post(uploadUrlPath, { filename });
|
|
45
72
|
if ('error' in minted)
|
|
46
|
-
return minted;
|
|
73
|
+
return { error: minted.error };
|
|
47
74
|
const uploadUrl = minted.data?.upload_url;
|
|
48
75
|
const key = minted.data?.key;
|
|
49
76
|
if (typeof uploadUrl !== 'string' || typeof key !== 'string') {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
77
|
+
return {
|
|
78
|
+
error: {
|
|
79
|
+
code: 'UPLOAD_URL_INVALID',
|
|
80
|
+
message: 'The upload-url response did not contain a usable upload_url and key.',
|
|
81
|
+
status: 0,
|
|
82
|
+
},
|
|
54
83
|
};
|
|
55
|
-
return { error };
|
|
56
84
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
85
|
+
return { uploadUrl, key };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Step 2: stream the file's bytes to the presigned URL — NO auth header (the URL
|
|
89
|
+
* is signed). Re-stats the file so Content-Length is its size at upload time,
|
|
90
|
+
* enforcing the byte ceiling and the FILE_NOT_FOUND (TOCTOU) contract there.
|
|
91
|
+
* Returns null on success, or a structured error.
|
|
92
|
+
*/
|
|
93
|
+
export async function putToPresignedUrl(client, uploadUrl, filePath, maxBytes = MAX_UPLOAD_BYTES, onProgress) {
|
|
94
|
+
// The file may vanish between an earlier stat and this transfer (a TOCTOU
|
|
95
|
+
// race); a structured FILE_NOT_FOUND is the contract, not a throw.
|
|
96
|
+
const stat = statReadableFile(filePath);
|
|
97
|
+
if (stat === null) {
|
|
98
|
+
return {
|
|
66
99
|
code: 'FILE_NOT_FOUND',
|
|
67
|
-
message: `The file at ${
|
|
100
|
+
message: `The file at ${filePath} could not be read.`,
|
|
68
101
|
status: 0,
|
|
69
102
|
};
|
|
70
|
-
return { error };
|
|
71
103
|
}
|
|
104
|
+
if (stat.size > maxBytes)
|
|
105
|
+
return fileTooLargeError(stat.size, maxBytes);
|
|
72
106
|
let putRes;
|
|
73
107
|
try {
|
|
74
|
-
|
|
108
|
+
// A stream body must declare Content-Length (a chunked PUT is rejected 411 by
|
|
109
|
+
// S3-compatible storage) and requires duplex: 'half' for undici's fetch.
|
|
110
|
+
let body = createReadStream(filePath);
|
|
111
|
+
if (onProgress !== undefined) {
|
|
112
|
+
// Count bytes as they pass through, inside the Transform (never a 'data'
|
|
113
|
+
// listener, which would flip the stream to flowing mode and race the
|
|
114
|
+
// reader). attachProgressCounter also forwards a source-stream error so a
|
|
115
|
+
// mid-transfer read failure becomes a clean UPLOAD_FAILED, not a crash.
|
|
116
|
+
body = attachProgressCounter(body, onProgress);
|
|
117
|
+
}
|
|
118
|
+
const putInit = {
|
|
75
119
|
method: 'PUT',
|
|
76
|
-
headers: {
|
|
77
|
-
|
|
78
|
-
|
|
120
|
+
headers: {
|
|
121
|
+
'Content-Type': contentType(filePath),
|
|
122
|
+
'Content-Length': String(stat.size),
|
|
123
|
+
},
|
|
124
|
+
body,
|
|
125
|
+
duplex: 'half',
|
|
126
|
+
};
|
|
127
|
+
putRes = await client.raw(uploadUrl, putInit);
|
|
79
128
|
}
|
|
80
129
|
catch (err) {
|
|
81
130
|
// Never surface err.message raw to the log — it can embed the signed URL,
|
|
@@ -83,22 +132,45 @@ export async function uploadViaPresignedUrl(client, opts) {
|
|
|
83
132
|
log('error', 'presigned upload PUT failed', {
|
|
84
133
|
reason: err instanceof Error ? err.message.replace(/https?:\/\/\S+/gi, '[url]') : 'network error',
|
|
85
134
|
});
|
|
86
|
-
|
|
87
|
-
code: 'UPLOAD_FAILED',
|
|
88
|
-
message: 'Uploading the file to storage failed.',
|
|
89
|
-
status: 0,
|
|
90
|
-
};
|
|
91
|
-
return { error };
|
|
135
|
+
return { code: 'UPLOAD_FAILED', message: 'Uploading the file to storage failed.', status: 0 };
|
|
92
136
|
}
|
|
93
137
|
if (!putRes.ok) {
|
|
94
|
-
|
|
95
|
-
const error = {
|
|
138
|
+
return {
|
|
96
139
|
code: 'UPLOAD_FAILED',
|
|
97
140
|
message: `Uploading the file to storage failed with status ${putRes.status}.`,
|
|
98
141
|
status: putRes.status,
|
|
99
142
|
};
|
|
100
|
-
return { error };
|
|
101
143
|
}
|
|
102
|
-
|
|
103
|
-
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
/** Step 3: commit the uploaded object key (idempotent — a retried commit will not duplicate). */
|
|
147
|
+
export function commitUpload(client, commitPath, key) {
|
|
148
|
+
return client.put(commitPath, { s3_key: key }, { idempotency: true });
|
|
149
|
+
}
|
|
150
|
+
export async function uploadViaPresignedUrl(client, opts) {
|
|
151
|
+
const limit = opts.maxBytes ?? MAX_UPLOAD_BYTES;
|
|
152
|
+
// Fail fast and locally: never touch the network for a file we cannot read,
|
|
153
|
+
// and reject an oversized file with an honest size error (not FILE_NOT_FOUND).
|
|
154
|
+
const initialStat = statReadableFile(opts.filePath);
|
|
155
|
+
if (initialStat === null) {
|
|
156
|
+
return {
|
|
157
|
+
error: {
|
|
158
|
+
code: 'FILE_NOT_FOUND',
|
|
159
|
+
message: `No readable file at ${opts.filePath}.`,
|
|
160
|
+
status: 0,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (initialStat.size > limit) {
|
|
165
|
+
return { error: fileTooLargeError(initialStat.size, limit) };
|
|
166
|
+
}
|
|
167
|
+
// Compose the seam: mint → PUT the bytes → commit. A failed PUT aborts before
|
|
168
|
+
// the commit, so a half-uploaded object is never finalized.
|
|
169
|
+
const minted = await mintUpload(client, opts.uploadUrlPath, basename(opts.filePath));
|
|
170
|
+
if ('error' in minted)
|
|
171
|
+
return { error: minted.error };
|
|
172
|
+
const putError = await putToPresignedUrl(client, minted.uploadUrl, opts.filePath, limit, opts.onProgress);
|
|
173
|
+
if (putError !== null)
|
|
174
|
+
return { error: putError };
|
|
175
|
+
return commitUpload(client, opts.commitPath, minted.key);
|
|
104
176
|
}
|
package/dist/entities.d.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* This is data, not behavior — the catalog tools stay thin wrappers and the
|
|
8
8
|
* API owns all validation. The wording here carries the caveats from the
|
|
9
9
|
* per-entity tool descriptions it replaces (recording_country on track create,
|
|
10
|
-
*
|
|
10
|
+
* the delete refusals). Caveats that belong to ONE tool stay in that tool's own
|
|
11
|
+
* description — the RELEASE_LOCKED_FIELDS rule lives on update_catalog_item, so
|
|
12
|
+
* repeating it here would only pad every client's catalog.
|
|
11
13
|
*/
|
|
12
14
|
export type EntityName = 'label' | 'artist' | 'writer' | 'publisher' | 'release' | 'track';
|
|
13
15
|
/** The entity names as a tuple, for zod enum inputs. */
|
package/dist/entities.js
CHANGED
|
@@ -7,45 +7,47 @@
|
|
|
7
7
|
* This is data, not behavior — the catalog tools stay thin wrappers and the
|
|
8
8
|
* API owns all validation. The wording here carries the caveats from the
|
|
9
9
|
* per-entity tool descriptions it replaces (recording_country on track create,
|
|
10
|
-
*
|
|
10
|
+
* the delete refusals). Caveats that belong to ONE tool stay in that tool's own
|
|
11
|
+
* description — the RELEASE_LOCKED_FIELDS rule lives on update_catalog_item, so
|
|
12
|
+
* repeating it here would only pad every client's catalog.
|
|
11
13
|
*/
|
|
12
14
|
/** The entity names as a tuple, for zod enum inputs. */
|
|
13
15
|
export const ENTITY_NAMES = ['label', 'artist', 'writer', 'publisher', 'release', 'track'];
|
|
14
16
|
export const ENTITIES = {
|
|
15
17
|
label: {
|
|
16
18
|
path: '/labels',
|
|
17
|
-
filtersDoc: 'label: no documented filters
|
|
19
|
+
filtersDoc: 'label: no documented filters.',
|
|
18
20
|
fieldsDoc: 'label — required: name, default_email; optional: support email, website/platform URLs, default copyright lines, isrc_base.',
|
|
19
21
|
deleteNote: 'label: refused while the label still has releases — remove or reassign its releases first.',
|
|
20
22
|
},
|
|
21
23
|
artist: {
|
|
22
24
|
path: '/artists',
|
|
23
|
-
filtersDoc: 'artist: artist_name
|
|
25
|
+
filtersDoc: 'artist: artist_name.',
|
|
24
26
|
fieldsDoc: 'artist — required: artist_name; optional: full_name, email, location, bios, isni, default_language, platform profile URLs.',
|
|
25
27
|
deleteNote: 'artist: refused while still referenced by releases or tracks.',
|
|
26
28
|
},
|
|
27
29
|
writer: {
|
|
28
30
|
path: '/writers',
|
|
29
|
-
filtersDoc: 'writer: name
|
|
31
|
+
filtersDoc: 'writer: name, ipi.',
|
|
30
32
|
fieldsDoc: 'writer — required: first_name, last_name; optional: middle_name, display_credits, email, country, pro, ipi, isni, publisher_id (or publisher_name/publisher_pro/publisher_ipi).',
|
|
31
33
|
deleteNote: 'writer: refused while still referenced by tracks.',
|
|
32
34
|
},
|
|
33
35
|
publisher: {
|
|
34
36
|
path: '/publishers',
|
|
35
|
-
filtersDoc: 'publisher: name
|
|
37
|
+
filtersDoc: 'publisher: name, ipi.',
|
|
36
38
|
fieldsDoc: 'publisher — required: name; optional: ipi, pro, isni, controlled_publisher.',
|
|
37
39
|
deleteNote: 'publisher: refused while still referenced by writers.',
|
|
38
40
|
},
|
|
39
41
|
release: {
|
|
40
42
|
path: '/releases',
|
|
41
|
-
filtersDoc: 'release: label_id
|
|
42
|
-
fieldsDoc: 'release — required on create: content_type, label_id, artists, titles, cat (catalog number), artwork_ai_usage, primary_genre_id; many optional fields (dates, copyright lines, genres, per-outlet URLs).
|
|
43
|
+
filtersDoc: 'release: label_id, is_live (1 = live only), barcode_number (UPC/EAN), cat.',
|
|
44
|
+
fieldsDoc: 'release — required on create: content_type, label_id, artists, titles, cat (catalog number), artwork_ai_usage, primary_genre_id; many optional fields (dates, copyright lines, genres, per-outlet URLs).',
|
|
43
45
|
deleteNote: 'release: only a never-submitted draft can be deleted.',
|
|
44
46
|
},
|
|
45
47
|
track: {
|
|
46
48
|
path: '/tracks',
|
|
47
|
-
filtersDoc: 'track: release_id
|
|
49
|
+
filtersDoc: 'track: release_id, isrc.',
|
|
48
50
|
fieldsDoc: 'track — required on create: release_id, disc, track_num, composition_type, artists, audio_ai_usage, composition_ai_usage, commercial_samples, audio_language, contributors, and recording_country (ISO 3166-1 alpha-2, e.g. "US"); optional: titles, isrc, iswc, writers, publishers, splits, and more.',
|
|
49
|
-
deleteNote: 'track:
|
|
51
|
+
deleteNote: 'track: refused once the release is no longer an editable draft.',
|
|
50
52
|
},
|
|
51
53
|
};
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared parsing for the configurable request/transfer timeouts, used by both
|
|
3
|
+
* the MCP server (env vars) and the CLI (flags and env vars). A value must be a
|
|
4
|
+
* positive integer number of milliseconds; anything else is rejected so the
|
|
5
|
+
* caller can fall back to the client default and warn once.
|
|
6
|
+
*/
|
|
7
|
+
export type TimeoutParse = {
|
|
8
|
+
/** The parsed positive-integer ms, or undefined when unset OR invalid. */
|
|
9
|
+
value: number | undefined;
|
|
10
|
+
/** True only when a value was supplied but was not a positive integer. */
|
|
11
|
+
invalid: boolean;
|
|
12
|
+
};
|
|
13
|
+
/** Parses a timeout string into a positive-integer millisecond value. */
|
|
14
|
+
export declare function parseTimeoutMs(raw: string | undefined): TimeoutParse;
|
package/dist/timeouts.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared parsing for the configurable request/transfer timeouts, used by both
|
|
3
|
+
* the MCP server (env vars) and the CLI (flags and env vars). A value must be a
|
|
4
|
+
* positive integer number of milliseconds; anything else is rejected so the
|
|
5
|
+
* caller can fall back to the client default and warn once.
|
|
6
|
+
*/
|
|
7
|
+
/** Parses a timeout string into a positive-integer millisecond value. */
|
|
8
|
+
export function parseTimeoutMs(raw) {
|
|
9
|
+
if (raw === undefined || raw.trim() === '')
|
|
10
|
+
return { value: undefined, invalid: false };
|
|
11
|
+
const n = Number(raw.trim());
|
|
12
|
+
if (Number.isInteger(n) && n > 0)
|
|
13
|
+
return { value: n, invalid: false };
|
|
14
|
+
return { value: undefined, invalid: true };
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@labelgrid/core",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Shared LabelGrid public-API client: HTTP transport, uploads, content types, the catalog-entity registry, and redacting logging",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": ["labelgrid", "music-distribution", "api-client"],
|