@labelgrid/core 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -8,6 +8,46 @@ 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.0] - 2026-07-23
12
+
13
+ ### Added
14
+
15
+ - `MAX_UPLOAD_BYTES` (4 GiB) upload ceiling and a `FILE_TOO_LARGE` structured
16
+ error. An oversized file is now rejected with an honest size-and-limit
17
+ message instead of being mislabeled `FILE_NOT_FOUND`.
18
+ - `mintUpload`, `putToPresignedUrl` and `commitUpload` — the presigned upload
19
+ flow's three steps are now individually exported and composed by
20
+ `uploadViaPresignedUrl`, so an alternate transport can reuse mint + commit and
21
+ swap only the byte-transfer step. Pure refactor; no behavior change.
22
+ - `LabelGridClient.getRaw(path, query?)` — an authenticated raw GET for file
23
+ downloads that returns the live response for streaming, or the same
24
+ normalized structured error as the JSON path, honoring the raw transfer
25
+ timeout. Consolidates the duplicated authed-download helpers that lived in the
26
+ MCP and CLI packages.
27
+
28
+ ### Changed
29
+
30
+ - Presigned-URL uploads now stream the file from disk with an explicit
31
+ `Content-Length` header instead of buffering the whole file into memory, so
32
+ peak memory stays flat regardless of file size. (An explicit `Content-Length`
33
+ is required — S3-compatible presigned PUTs reject a chunked body with 411.)
34
+ - Multipart uploads no longer make a redundant in-memory copy of the file bytes
35
+ before building the form Blob.
36
+ - `UploadOptions` accepts an optional `onProgress` callback, invoked with the
37
+ running byte count as the presigned PUT streams (for a transfer-progress UI).
38
+
39
+ ### Fixed
40
+
41
+ - Multipart uploads (cover art, license documents) now use the longer raw
42
+ transfer timeout instead of the 60s JSON request timeout, so a large file on
43
+ a slow uplink is no longer aborted mid-upload.
44
+ - `raw()` now composes a caller-supplied `AbortSignal` with the transfer-timeout
45
+ signal (`AbortSignal.any`) instead of letting the caller's signal silently
46
+ replace and disable the timeout.
47
+ - Presigned-upload progress reporting now forwards a source-stream error onto
48
+ the composed request body, so a file that becomes unreadable mid-transfer
49
+ fails cleanly as `UPLOAD_FAILED` instead of raising an unhandled stream error.
50
+
11
51
  ## [0.1.0] - 2026-07-20
12
52
 
13
53
  ### Added
@@ -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
- const init = { method, headers, signal: AbortSignal.timeout(this.timeoutMs) };
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(this.timeoutMs / 1000)} seconds. Try again, or narrow the request.`,
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(this.timeoutMs / 1000)} seconds while reading the response. Try again, or narrow the request.`,
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
- form.append(fieldName, new Blob([new Uint8Array(bytes)], { type: contentType(filePath) }), basename(filePath));
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
- return this.fetchFn(url, { signal: AbortSignal.timeout(this.rawTimeoutMs), ...init });
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
  }
@@ -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
- import type { ApiResult, LabelGridClient } from './http.js';
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>>;
@@ -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
- /** True only for an existing regular file. */
23
- function isReadableFile(p) {
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
- return statSync(p).isFile();
34
+ const st = statSync(p);
35
+ return st.isFile() ? { size: st.size } : null;
26
36
  }
27
37
  catch {
28
- return false;
38
+ return null;
29
39
  }
30
40
  }
31
- export async function uploadViaPresignedUrl(client, opts) {
32
- // Fail fast and locally: never touch the network for a file we cannot read.
33
- if (!isReadableFile(opts.filePath)) {
34
- const error = {
35
- code: 'FILE_NOT_FOUND',
36
- message: `No readable file at ${opts.filePath}.`,
37
- status: 0,
38
- };
39
- return { error };
40
- }
41
- // Step 1: mint the presigned URL.
42
- const minted = await client.post(opts.uploadUrlPath, {
43
- filename: basename(opts.filePath),
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
- const error = {
51
- code: 'UPLOAD_URL_INVALID',
52
- message: 'The upload-url response did not contain a usable upload_url and key.',
53
- status: 0,
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
- // Step 2: PUT the bytes directly to storage — NO auth header (the URL is signed).
58
- // The file passed isReadableFile above, but it can vanish before this read
59
- // (a TOCTOU race); a structured FILE_NOT_FOUND is the contract, not a throw.
60
- let bytes;
61
- try {
62
- bytes = await readFile(opts.filePath);
63
- }
64
- catch {
65
- const error = {
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 ${opts.filePath} could not be read.`,
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
- putRes = await client.raw(uploadUrl, {
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: { 'Content-Type': contentType(opts.filePath) },
77
- body: new Uint8Array(bytes),
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
- const error = {
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
- // Abort BEFORE the commit — a half-uploaded object is never finalized.
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
- // Step 3: commit the object key (idempotent — a retried commit will not duplicate).
103
- return client.put(opts.commitPath, { s3_key: key }, { idempotency: true });
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/index.d.ts CHANGED
@@ -11,3 +11,4 @@ export * from './api/http.js';
11
11
  export * from './api/upload.js';
12
12
  export * from './entities.js';
13
13
  export * from './log.js';
14
+ export * from './timeouts.js';
package/dist/index.js CHANGED
@@ -11,3 +11,4 @@ export * from './api/http.js';
11
11
  export * from './api/upload.js';
12
12
  export * from './entities.js';
13
13
  export * from './log.js';
14
+ export * from './timeouts.js';
@@ -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;
@@ -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,9 +1,13 @@
1
1
  {
2
2
  "name": "@labelgrid/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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
- "keywords": ["labelgrid", "music-distribution", "api-client"],
6
+ "keywords": [
7
+ "labelgrid",
8
+ "music-distribution",
9
+ "api-client"
10
+ ],
7
11
  "main": "dist/index.js",
8
12
  "types": "dist/index.d.ts",
9
13
  "exports": {
@@ -12,7 +16,12 @@
12
16
  "default": "./dist/index.js"
13
17
  }
14
18
  },
15
- "files": ["dist", "README.md", "CHANGELOG.md", "LICENSE"],
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "CHANGELOG.md",
23
+ "LICENSE"
24
+ ],
16
25
  "scripts": {
17
26
  "build": "tsc",
18
27
  "dev": "tsc --watch",