@openneuro/server 4.47.7 → 5.0.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 (45) hide show
  1. package/package.json +10 -7
  2. package/src/app.ts +1 -1
  3. package/src/cache/__tests__/tree.spec.ts +212 -0
  4. package/src/cache/tree.ts +148 -0
  5. package/src/datalad/__tests__/dataRetentionNotifications.spec.ts +11 -0
  6. package/src/datalad/__tests__/files.spec.ts +249 -0
  7. package/src/datalad/dataRetentionNotifications.ts +5 -0
  8. package/src/datalad/dataset.ts +29 -1
  9. package/src/datalad/files.ts +362 -39
  10. package/src/datalad/snapshots.ts +29 -54
  11. package/src/graphql/resolvers/__tests__/response-status.spec.ts +42 -0
  12. package/src/graphql/resolvers/build-search-query.ts +391 -0
  13. package/src/graphql/resolvers/cache.ts +5 -1
  14. package/src/graphql/resolvers/dataset-search.ts +40 -23
  15. package/src/graphql/resolvers/datasetEvents.ts +48 -78
  16. package/src/graphql/resolvers/draft.ts +5 -2
  17. package/src/graphql/resolvers/holdDeletion.ts +21 -0
  18. package/src/graphql/resolvers/index.ts +6 -0
  19. package/src/graphql/resolvers/mutation.ts +2 -0
  20. package/src/graphql/resolvers/response-status.ts +43 -0
  21. package/src/graphql/resolvers/snapshots.ts +9 -18
  22. package/src/graphql/resolvers/summary.ts +17 -0
  23. package/src/graphql/schema.ts +54 -14
  24. package/src/handlers/datalad.ts +4 -0
  25. package/src/handlers/doi.ts +32 -36
  26. package/src/libs/doi/__tests__/doi.spec.ts +50 -12
  27. package/src/libs/doi/__tests__/validate.spec.ts +110 -0
  28. package/src/libs/doi/index.ts +108 -71
  29. package/src/libs/doi/metadata.ts +101 -0
  30. package/src/libs/doi/validate.ts +59 -0
  31. package/src/libs/presign.ts +137 -0
  32. package/src/models/dataset.ts +2 -0
  33. package/src/models/doi.ts +7 -0
  34. package/src/queues/producer-methods.ts +9 -5
  35. package/src/queues/queue-schedule.ts +1 -1
  36. package/src/queues/queues.ts +2 -2
  37. package/src/routes.ts +10 -2
  38. package/src/types/datacite/LICENSE +37 -0
  39. package/src/types/datacite/README.md +3 -0
  40. package/src/types/datacite/datacite-v4.5.json +643 -0
  41. package/src/types/datacite/datacite-v4.5.ts +281 -0
  42. package/src/types/datacite.ts +53 -63
  43. package/src/utils/datacite-mapper.ts +7 -3
  44. package/src/utils/datacite-utils.ts +12 -15
  45. package/src/libs/doi/__tests__/__snapshots__/doi.spec.ts.snap +0 -17
@@ -3,11 +3,25 @@ import {
3
3
  computeTotalSize,
4
4
  decodeFilePath,
5
5
  encodeFilePath,
6
+ entryToDatasetFile,
6
7
  fileUrl,
8
+ getFileName,
9
+ parseS3Url,
10
+ workerFileToEntry,
7
11
  } from "../files"
12
+ import type { TreeEntry } from "../../cache/tree"
8
13
 
9
14
  vi.mock("ioredis")
10
15
  vi.mock("../../config.ts")
16
+ vi.mock("../../libs/redis", () => ({ redis: {} }))
17
+ vi.mock("../../libs/presign", () => ({
18
+ getPresignedUrl: vi.fn().mockResolvedValue(
19
+ "https://s3.amazonaws.com/bucket/key?presigned=true",
20
+ ),
21
+ publicS3Url: vi.fn().mockReturnValue(
22
+ "https://s3.amazonaws.com/bucket/key?versionId=abc123",
23
+ ),
24
+ }))
11
25
 
12
26
  const filename = "sub-01/anat/sub-01_T1w.nii.gz"
13
27
 
@@ -49,4 +63,239 @@ describe("datalad files", () => {
49
63
  expect(computeTotalSize(mockFileSizes)).toBe(1957206)
50
64
  })
51
65
  })
66
+ describe("entryToDatasetFile()", () => {
67
+ it("returns a directory entry", async () => {
68
+ const entry: TreeEntry = {
69
+ n: "sub-01",
70
+ h: "abc123",
71
+ s: 0,
72
+ k: "",
73
+ v: "",
74
+ b: "",
75
+ p: false,
76
+ d: true,
77
+ }
78
+ const result = await entryToDatasetFile(entry, "ds000001")
79
+ expect(result).toEqual({
80
+ id: "abc123",
81
+ filename: "sub-01",
82
+ directory: true,
83
+ size: 0,
84
+ urls: [],
85
+ })
86
+ })
87
+ it("returns a presigned URL for private files", async () => {
88
+ const entry: TreeEntry = {
89
+ n: "sub-01_T1w.nii.gz",
90
+ h: "def456",
91
+ s: 12345,
92
+ k: "datasets/ds000001/sub-01_T1w.nii.gz",
93
+ v: "ver1",
94
+ b: "private-bucket",
95
+ p: true,
96
+ d: false,
97
+ }
98
+ const result = await entryToDatasetFile(entry, "ds000001")
99
+ expect(result).toEqual({
100
+ id: "def456",
101
+ filename: "sub-01_T1w.nii.gz",
102
+ directory: false,
103
+ size: 12345,
104
+ urls: ["https://s3.amazonaws.com/bucket/key?presigned=true"],
105
+ })
106
+ })
107
+ it("returns a public S3 URL for public files with S3 keys", async () => {
108
+ const entry: TreeEntry = {
109
+ n: "README",
110
+ h: "ghi789",
111
+ s: 500,
112
+ k: "datasets/ds000001/README",
113
+ v: "ver2",
114
+ b: "",
115
+ p: false,
116
+ d: false,
117
+ }
118
+ const result = await entryToDatasetFile(entry, "ds000001")
119
+ expect(result).toEqual({
120
+ id: "ghi789",
121
+ filename: "README",
122
+ directory: false,
123
+ size: 500,
124
+ urls: ["https://s3.amazonaws.com/bucket/key?versionId=abc123"],
125
+ })
126
+ })
127
+ it("falls back to server object URL when no S3 key/version", async () => {
128
+ process.env.CRN_SERVER_URL = "https://openneuro.org"
129
+ const entry: TreeEntry = {
130
+ n: "sub-01_T1w.nii.gz",
131
+ h: "jkl012",
132
+ s: 9999,
133
+ k: "",
134
+ v: "",
135
+ b: "",
136
+ p: false,
137
+ d: false,
138
+ }
139
+ const result = await entryToDatasetFile(entry, "ds000001")
140
+ expect(result).toEqual({
141
+ id: "jkl012",
142
+ filename: "sub-01_T1w.nii.gz",
143
+ directory: false,
144
+ size: 9999,
145
+ urls: [
146
+ "https://openneuro.org/crn/datasets/ds000001/objects/jkl012?filename=sub-01_T1w.nii.gz",
147
+ ],
148
+ })
149
+ })
150
+ })
151
+ describe("parseS3Url()", () => {
152
+ it("parses a standard S3 URL with versionId", () => {
153
+ expect(
154
+ parseS3Url(
155
+ "https://s3.amazonaws.com/openneuro.org/datasets/ds000001/sub-01_T1w.nii.gz?versionId=abc123",
156
+ ),
157
+ ).toEqual({
158
+ bucket: "openneuro.org",
159
+ s3Key: "datasets/ds000001/sub-01_T1w.nii.gz",
160
+ versionId: "abc123",
161
+ })
162
+ })
163
+ it("returns empty versionId when missing", () => {
164
+ const result = parseS3Url(
165
+ "https://s3.amazonaws.com/openneuro.org/datasets/ds000001/README",
166
+ )
167
+ expect(result).toEqual({
168
+ bucket: "openneuro.org",
169
+ s3Key: "datasets/ds000001/README",
170
+ versionId: "",
171
+ })
172
+ })
173
+ it("decodes percent-encoded path components", () => {
174
+ const result = parseS3Url(
175
+ "https://s3.amazonaws.com/bucket/path%20with%20spaces/file%2B1.txt?versionId=v1",
176
+ )
177
+ expect(result).toEqual({
178
+ bucket: "bucket",
179
+ s3Key: "path with spaces/file+1.txt",
180
+ versionId: "v1",
181
+ })
182
+ })
183
+ it("returns null for invalid URLs", () => {
184
+ expect(parseS3Url("not-a-url")).toBeNull()
185
+ })
186
+ })
187
+ describe("workerFileToEntry()", () => {
188
+ it("converts a directory file", () => {
189
+ const file = {
190
+ id: "tree-hash",
191
+ filename: "sub-01",
192
+ directory: true,
193
+ size: 0,
194
+ urls: [],
195
+ }
196
+ expect(workerFileToEntry(file, false)).toEqual({
197
+ n: "sub-01",
198
+ h: "tree-hash",
199
+ s: 0,
200
+ k: "",
201
+ v: "",
202
+ b: "",
203
+ p: false,
204
+ d: true,
205
+ })
206
+ })
207
+ it("converts a public file with S3 URL", () => {
208
+ const file = {
209
+ id: "blob-hash",
210
+ filename: "README",
211
+ directory: false,
212
+ size: 500,
213
+ urls: [
214
+ "https://s3.amazonaws.com/openneuro.org/datasets/ds000001/README?versionId=ver2",
215
+ ],
216
+ }
217
+ const result = workerFileToEntry(file, false)
218
+ expect(result).toEqual({
219
+ n: "README",
220
+ h: "blob-hash",
221
+ s: 500,
222
+ k: "datasets/ds000001/README",
223
+ v: "ver2",
224
+ b: "openneuro.org",
225
+ p: false,
226
+ d: false,
227
+ })
228
+ })
229
+ it("stores empty bucket when it matches the default bucket", () => {
230
+ const originalBucket = process.env.AWS_S3_PUBLIC_BUCKET
231
+ process.env.AWS_S3_PUBLIC_BUCKET = "openneuro.org"
232
+ const file = {
233
+ id: "blob-hash",
234
+ filename: "README",
235
+ directory: false,
236
+ size: 500,
237
+ urls: [
238
+ "https://s3.amazonaws.com/openneuro.org/datasets/ds000001/README?versionId=ver2",
239
+ ],
240
+ }
241
+ const result = workerFileToEntry(file, false)
242
+ expect(result.b).toBe("")
243
+ process.env.AWS_S3_PUBLIC_BUCKET = originalBucket
244
+ })
245
+ it("sets presign flag from needsPresign parameter", () => {
246
+ const file = {
247
+ id: "blob-hash",
248
+ filename: "data.nii.gz",
249
+ directory: false,
250
+ size: 1000,
251
+ urls: [
252
+ "https://s3.amazonaws.com/private-bucket/data.nii.gz?versionId=v1",
253
+ ],
254
+ }
255
+ expect(workerFileToEntry(file, true).p).toBe(true)
256
+ expect(workerFileToEntry(file, false).p).toBe(false)
257
+ })
258
+ it("handles a file with no URLs", () => {
259
+ const file = {
260
+ id: "blob-hash",
261
+ filename: "data.nii.gz",
262
+ directory: false,
263
+ size: 1000,
264
+ urls: [],
265
+ }
266
+ const result = workerFileToEntry(file, false)
267
+ expect(result.k).toBe("")
268
+ expect(result.v).toBe("")
269
+ expect(result.b).toBe("")
270
+ })
271
+ })
272
+ describe("fileUrl() with revision", () => {
273
+ it("includes the revision in the URL path", () => {
274
+ expect(
275
+ fileUrl("ds000001", "", "README", "abc123def"),
276
+ ).toBe(
277
+ "http://datalad-0/datasets/ds000001/snapshots/abc123def/files/README",
278
+ )
279
+ })
280
+ it("encodes nested paths with a revision", () => {
281
+ expect(
282
+ fileUrl("ds000001", "sub-01/anat", "sub-01_T1w.nii.gz", "abc123def"),
283
+ ).toBe(
284
+ "http://datalad-0/datasets/ds000001/snapshots/abc123def/files/sub-01:anat:sub-01_T1w.nii.gz",
285
+ )
286
+ })
287
+ })
288
+ describe("getFileName()", () => {
289
+ it("joins path and filename", () => {
290
+ expect(getFileName("sub-01/anat", "sub-01_T1w.nii.gz")).toBe(
291
+ "sub-01:anat:sub-01_T1w.nii.gz",
292
+ )
293
+ })
294
+ it("returns encoded filename when path is empty", () => {
295
+ expect(getFileName("", "README")).toBe("README")
296
+ })
297
+ it("returns encoded path when filename is empty", () => {
298
+ expect(getFileName("sub-01/anat", "")).toBe("sub-01:anat")
299
+ })
300
+ })
52
301
  })
@@ -2,6 +2,7 @@ import config from "../config"
2
2
  import notifications from "../libs/notifications"
3
3
  import User from "../models/user"
4
4
  import Permission from "../models/permission"
5
+ import Dataset from "../models/dataset"
5
6
  import DataRetention from "../models/dataRetention"
6
7
  import Deletion from "../models/deletion"
7
8
  import { getDraftInfo } from "./draft"
@@ -45,6 +46,10 @@ export async function checkDataRetentionNotifications(
45
46
  const deleted = await Deletion.findOne({ datasetId }).exec()
46
47
  if (deleted) return
47
48
 
49
+ // Skip datasets with deletion hold set by an admin
50
+ const dataset = await Dataset.findOne({ id: datasetId }).exec()
51
+ if (dataset?.holdDeletion) return
52
+
48
53
  const draft = await getDraftInfo(datasetId)
49
54
  const snapshots = await getSnapshots(datasetId)
50
55
  const lastSnapshot = snapshots?.length
@@ -26,6 +26,8 @@ import BadAnnexObject from "../models/badAnnexObject"
26
26
  import { datasetsConnection } from "./pagination"
27
27
  import { getDatasetWorker } from "../libs/datalad-service"
28
28
  import { createEvent, updateEvent } from "../libs/events"
29
+ import Doi from "../models/doi"
30
+ import { hideDoi, publishDoi } from "../libs/doi/index"
29
31
 
30
32
  export const giveUploaderPermission = (datasetId, userId) => {
31
33
  const permission = new Permission({ datasetId, userId, level: "admin" })
@@ -490,7 +492,7 @@ export const flagAnnexObject = (
490
492
  }
491
493
 
492
494
  /**
493
- * Update public state
495
+ * Update public state and transition DOI states accordingly.
494
496
  */
495
497
  export async function updatePublic(datasetId, publicFlag, user) {
496
498
  const event = await createEvent(
@@ -503,6 +505,32 @@ export async function updatePublic(datasetId, publicFlag, user) {
503
505
  { public: publicFlag, publishDate: new Date() },
504
506
  ).exec()
505
507
  await updateEvent(event)
508
+
509
+ // Transition DOI states
510
+ try {
511
+ if (publicFlag) {
512
+ // Draft transition to Findable for all DOIs on this dataset
513
+ const draftDois = await Doi.find({ datasetId, state: "draft" })
514
+ for (const record of draftDois) {
515
+ await publishDoi(record.doi)
516
+ record.state = "findable"
517
+ await record.save()
518
+ }
519
+ } else {
520
+ // Findable transition to Registered when unpublishing
521
+ const findableDois = await Doi.find({
522
+ datasetId,
523
+ state: { $in: ["findable", null] },
524
+ })
525
+ for (const record of findableDois) {
526
+ await hideDoi(record.doi)
527
+ record.state = "registered"
528
+ await record.save()
529
+ }
530
+ }
531
+ } catch (err) {
532
+ Sentry.captureException(err)
533
+ }
506
534
  }
507
535
 
508
536
  export const getDatasetAnalytics = (datasetId, _tag) => {