@myelinbridge/cli 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.
Files changed (3) hide show
  1. package/README.md +4 -1
  2. package/bin/myelin.js +70 -22
  3. package/package.json +2 -5
package/README.md CHANGED
@@ -16,7 +16,10 @@ npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
16
16
  ```
17
17
 
18
18
  - **Resume = re-run.** `push` is idempotent: already-uploaded files are skipped
19
- (path + size), interrupted uploads resume mid-file over TUS.
19
+ (path + size), and within a large file, parts that already landed are skipped
20
+ too (S3 multipart). Uploads go direct to storage over short-lived presigned
21
+ URLs — no credential is stored on your machine, and revoking the API key cuts
22
+ off signing immediately.
20
23
  - **`check` costs nothing.** It evaluates your local manifest against the dataset's
21
24
  quality checks server-side — same engine, same verdicts as submit — without
22
25
  uploading. Exit code 2 means a blocking rule fails.
package/bin/myelin.js CHANGED
@@ -11,7 +11,6 @@
11
11
  import { readdirSync, statSync, createReadStream, readFileSync } from 'node:fs'
12
12
  import { resolve, join, relative, sep, basename } from 'node:path'
13
13
  import process from 'node:process'
14
- import * as tus from 'tus-js-client'
15
14
 
16
15
  const API_URL = (process.env.MYELIN_API_URL ?? 'https://myelinbridge.com/api/v1').replace(/\/$/, '')
17
16
  const KEY = process.env.MYELIN_API_KEY
@@ -97,24 +96,71 @@ function fmtBytes(n) {
97
96
  return n + ' B'
98
97
  }
99
98
 
100
- function tusUpload(absPath, size, storagePath, upload) {
101
- return new Promise((resolveP, rejectP) => {
102
- new tus.Upload(createReadStream(absPath), {
103
- endpoint: upload.endpoint,
104
- chunkSize: upload.chunk_size_bytes,
105
- uploadSize: size,
106
- retryDelays: [0, 1000, 5000],
107
- headers: { authorization: `Bearer ${upload.token}`, apikey: upload.apikey, ...upload.headers },
108
- metadata: {
109
- bucketName: upload.bucket,
110
- objectName: storagePath,
111
- contentType: 'application/octet-stream',
112
- cacheControl: '3600',
113
- },
114
- onError: rejectP,
115
- onSuccess: () => resolveP(),
116
- }).start()
99
+ // Upload a file via S3 presigned multipart (SEC-TOKEN-01: no bearer token ever
100
+ // reaches the client — declare tells us the part size, and each part gets a
101
+ // short-lived presigned PUT URL scoped to exactly that one part of one object).
102
+ // Resumable: the sign step reports parts that already landed, so a re-run skips
103
+ // them. Returns { upload_id, parts: [{part_number, etag}] } for confirm.
104
+ const SIGN_WINDOW = 1000 // ≤ the server's max_parts_per_sign
105
+
106
+ async function readPart(absPath, start, end) {
107
+ const chunks = []
108
+ await new Promise((res, rej) => {
109
+ const s = createReadStream(absPath, { start, end }) // end inclusive
110
+ s.on('data', (c) => chunks.push(c))
111
+ s.on('end', res)
112
+ s.on('error', rej)
117
113
  })
114
+ return Buffer.concat(chunks)
115
+ }
116
+
117
+ async function uploadFileMultipart(absPath, size, fileId, partSize, onPartDone) {
118
+ const numParts = Math.max(1, Math.ceil(size / partSize))
119
+ const allParts = Array.from({ length: numParts }, (_, i) => i + 1)
120
+ const etags = new Map() // part_number -> etag
121
+ let uploadId = null
122
+
123
+ for (let i = 0; i < allParts.length; i += SIGN_WINDOW) {
124
+ const window = allParts.slice(i, i + SIGN_WINDOW)
125
+ const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: window }), 'sign upload parts')
126
+ uploadId = j.upload_id
127
+ for (const p of j.uploaded_parts) etags.set(p.part_number, p.etag) // already landed → skip
128
+ const urlByPart = new Map(j.urls.map((u) => [u.part_number, u.url]))
129
+
130
+ // upload the still-missing parts in this window, up to 4 concurrent.
131
+ // Presigned URLs expire (~30 min); on a rejection we re-sign that one part
132
+ // and retry once — cheap, since upload-parts is idempotent.
133
+ const missing = window.filter((n) => !etags.has(n))
134
+ const queue = [...missing]
135
+ const resign = async (partNo) => {
136
+ const j = expectOk(await api('POST', `/files/${fileId}/upload-parts`, { part_numbers: [partNo] }), 're-sign upload part')
137
+ const fresh = j.urls.find((u) => u.part_number === partNo)?.url
138
+ if (!fresh) die(`upload part ${partNo}: could not re-sign (already finalized?)`)
139
+ urlByPart.set(partNo, fresh)
140
+ return fresh
141
+ }
142
+ const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
143
+ for (;;) {
144
+ const partNo = queue.shift()
145
+ if (partNo === undefined) return
146
+ const start = (partNo - 1) * partSize
147
+ const end = Math.min(start + partSize, size) - 1
148
+ const bytes = await readPart(absPath, start, end)
149
+ let put = await fetch(urlByPart.get(partNo), { method: 'PUT', body: bytes }).catch(() => null)
150
+ if (!put || put.status === 403 || put.status === 401) {
151
+ put = await fetch(await resign(partNo), { method: 'PUT', body: bytes }).catch((e) => die(`upload part ${partNo}: ${e.message}`))
152
+ }
153
+ if (put.status !== 200) die(`upload part ${partNo}: HTTP ${put.status}`)
154
+ const etag = put.headers.get('etag')
155
+ if (!etag) die(`upload part ${partNo}: storage returned no ETag`)
156
+ etags.set(partNo, etag)
157
+ if (onPartDone) onPartDone()
158
+ }
159
+ })
160
+ await Promise.all(workers)
161
+ }
162
+
163
+ return { upload_id: uploadId, parts: allParts.map((n) => ({ part_number: n, etag: etags.get(n) })) }
118
164
  }
119
165
 
120
166
  // ——— commands ———
@@ -226,20 +272,22 @@ async function cmdPush() {
226
272
  declared.push(...r.json.files)
227
273
  upload = r.json.upload
228
274
  }
275
+ const partSize = upload?.part_size ?? 64 * 1024 * 1024
229
276
 
230
- // 3. upload the delta (anything not already 'uploaded'), 4 in parallel
277
+ // 3. upload the delta (anything not already 'uploaded') via S3 multipart,
278
+ // 2 files in parallel (each file uploads its parts 4-wide internally)
231
279
  const byPath = new Map(files.map((f) => [f.path, f]))
232
280
  const pending = declared.filter((d) => d.upload_state !== 'uploaded')
233
281
  out(`Uploading ${pending.length}/${declared.length} files (${fmtBytes(pending.reduce((s, d) => s + (byPath.get(d.path)?.size ?? 0), 0))}) · resumable`)
234
282
  let done = 0
235
283
  const queue = [...pending]
236
- const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
284
+ const workers = Array.from({ length: Math.min(2, queue.length) }, async () => {
237
285
  for (;;) {
238
286
  const d = queue.shift()
239
287
  if (!d) return
240
288
  const local = byPath.get(d.path)
241
- await tusUpload(local.abs, local.size, d.storage_path, upload)
242
- const c = await api('POST', `/files/${d.file_id}/confirm`)
289
+ const { upload_id, parts } = await uploadFileMultipart(local.abs, local.size, d.file_id, partSize)
290
+ const c = await api('POST', `/files/${d.file_id}/confirm`, { upload_id, parts })
243
291
  if (c.status !== 200) die(`confirm ${d.path}: [${c.json?.code}] ${c.json?.detail ?? c.status}`)
244
292
  done++
245
293
  out(` ✓ ${d.path} (${done}/${pending.length})`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myelinbridge/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Myelin Partner Ingestion CLI — push R&D data deliveries from a pipeline: preflight against the client's quality rules, resumable upload, submit, track review outcomes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,14 +13,11 @@
13
13
  "engines": {
14
14
  "node": ">=20"
15
15
  },
16
- "dependencies": {
17
- "tus-js-client": "^4.1.0"
18
- },
19
16
  "keywords": [
20
17
  "myelin",
21
18
  "pharma",
22
19
  "ingestion",
23
- "tus"
20
+ "s3"
24
21
  ],
25
22
  "license": "UNLICENSED"
26
23
  }