atlas_rb 1.10.0 → 1.11.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 568ae8b7b6c4f459c528c79d358e5969333b0d35201c5b5f8342931bff232d00
4
- data.tar.gz: bbfa0f0f70bafc15c5fb402ce3051795250716e3229b8cb3b70e9fec5b62af19
3
+ metadata.gz: 58d8d5524e43dc1f14f172ea65bc1e78e55826a63c814f2ef517675d07732d08
4
+ data.tar.gz: 548d4cba38f7a2ef81f210612157ee36a56de6e15b5fcff367b4df1b73fda847
5
5
  SHA512:
6
- metadata.gz: 22976962a78e3aa7c53253db913daa0306a27c8d378490443a774acaeac6afb83d6ec7b0da15942ad9c276dd7dd454ce9ccae26880189b61fa94dfbdf8977fc9
7
- data.tar.gz: 634edc0528a3872d201b67506030d0ed88d5956da985336ba9cebf7b78cc49a997a5806dc2b51ec2071774b499ac2f4c1f2235f25dfc784a1d04e7cf0c9b9123
6
+ metadata.gz: 745c494de1bd2da2a0048e2d3d562961bed6e48058b30da25dcbcdc0f9b2b7f3159c2b831508ac972a968dc6f02ee2a7f0a5cf11b85e2319277d1f200adf51f4
7
+ data.tar.gz: fe90db7467cdae4726357c2ba6d0d939d50c4174f1a3aa43ee8fd91adea19923fa321f53804fb52ed8db39eab22b578dadc6e542f77d54d1e1177e1effab0c53
data/.version CHANGED
@@ -1 +1 @@
1
- 1.10.0
1
+ 1.11.0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,103 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.11.0
4
+
5
+ ### Added — `Work.associations` / `.associate` / `.disassociate`
6
+
7
+ DRS v1 let a depositor declare that one object is the codebook, figure,
8
+ transcription, instructional material or supplemental material **for** another.
9
+ The two objects stayed separate records, and each one's page showed the link
10
+ from its own end. Atlas now records this again, and these three bindings reach
11
+ it.
12
+
13
+ ```ruby
14
+ AtlasRb::Work.associate(codebook_id, dataset_id, type: "is_codebook_for")
15
+ AtlasRb::Work.associations(dataset_id)
16
+ # => {"outbound" => {}, "inbound" => {"is_codebook_for" => ["w-codebook"]}}
17
+ AtlasRb::Work.disassociate(codebook_id, dataset_id, type: "is_codebook_for")
18
+ ```
19
+
20
+ All three return the same shape, so a mutation needs no follow-up read.
21
+ `outbound` is what the Work asserts; `inbound` is what other Works assert about
22
+ it. The edge is stored once, on the asserting Work, and Atlas derives the other
23
+ direction — so the two can never drift apart.
24
+
25
+ The five predicates are in `AtlasRb::Work::ASSOCIATION_TYPES`, for building a
26
+ select box without hard-coding the vocabulary. A sixth needs an Atlas release,
27
+ so the list cannot get ahead of the server.
28
+
29
+ Writes are admin / devolved-admin only: the claim renders on the target's page
30
+ too, and the asserter often holds no rights over it.
31
+
32
+ ### Added — `WorkAssociationError`
33
+
34
+ A `422` on an association path now raises a typed error carrying `#code`
35
+ (`invalid_type`, `target_not_found`, `invalid_target_type`,
36
+ `self_association`, `tombstoned_work`, `tombstoned_target`). A `403` raises the
37
+ existing `ForbiddenError`. Without these the binding would return the envelope
38
+ as if it were a success and the rejection would be silently discarded.
39
+
40
+ ### Changed — the `destroy` docstrings now describe a purge
41
+
42
+ `Admin::{Work,Collection,Community}.destroy`, `FileSet.destroy` and
43
+ `Blob.destroy` previously promised more than the server did: the docstrings
44
+ claimed a cascade and removal "from Atlas storage" while the server deleted one
45
+ Postgres row and one Solr document, leaving every byte on disk.
46
+
47
+ Atlas has since made `destroy` mean what the docstrings said, and they are
48
+ updated to match what it now does — including the parts they never mentioned:
49
+
50
+ - Deleting a Blob or a FileSet removes the **whole OCFL object**, so every
51
+ retained revision goes, not only the current one. `versions` and `rollback`
52
+ have nothing left to work with afterwards.
53
+ - `Admin::Collection.destroy` and `Admin::Community.destroy` refuse with a
54
+ `422` (`has_children`) while the container still holds a member, **including
55
+ a tombstoned one**. That is stricter than `tombstone`, which counts only live
56
+ members: a purge cannot be undone, so a member left behind is orphaned for
57
+ good.
58
+
59
+ No signature changed; this release is additive.
60
+
61
+ ## 1.10.1
62
+
63
+ ### Added — `Work.mark_incomplete` / `Work.clear_incomplete`
64
+
65
+ A Work can now carry a second lifecycle state. `complete` says the deposit
66
+ finished; `incomplete` says something downstream of it gave up. Before this
67
+ there was no way to record that at all: every give-up handler wrote a
68
+ `Rails.logger.warn` and nothing else, so a deposit whose PDF rendition
69
+ exhausted its retries ended up with no rendition and no thumbnail, the
70
+ depositor was not told, and no surface listed it.
71
+
72
+ ```ruby
73
+ AtlasRb::Work.mark_incomplete(work_id, reason: "pdf_rendition_gave_up")
74
+ AtlasRb::Work.clear_incomplete(work_id)
75
+ ```
76
+
77
+ `reason` is a machine token, one per give-up handler. Atlas holds it as an
78
+ opaque string and does **not** validate it against a list, so the vocabulary
79
+ belongs to the caller and a new token needs no Atlas release. Map it to display
80
+ text at the point of use, with a fallback for a token the view has not been
81
+ taught.
82
+
83
+ The flag **never hides** the Work. A record with its file, title and metadata
84
+ but one missing derivative is degraded, not broken, and stays readable.
85
+
86
+ Both bindings return the updated Work. `mark_incomplete` is idempotent and the
87
+ last reason wins; `clear_incomplete` is idempotent too, and clears the flag and
88
+ the reason together. Call it when a later run of the same job succeeds, which
89
+ makes the state self-healing.
90
+
91
+ ### Added — `incomplete:` filter on `Work.list`
92
+
93
+ `AtlasRb::Work.list(incomplete: true)` is the staff list of Works whose
94
+ pipeline gave up, the sibling of the existing `in_progress:` "what's stuck?"
95
+ view. The two are independent and combine: `in_progress: false, incomplete:
96
+ true` reads as "finished, but degraded".
97
+
98
+ Work summaries in the response now carry `incomplete` and `incomplete_reason`
99
+ alongside `in_progress`.
100
+
3
101
  ## 1.10.0
4
102
 
5
103
  ### Removed — `title` on `Person.create` and `Person.update`
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.10.0)
4
+ atlas_rb (1.11.0)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
data/README.md CHANGED
@@ -255,6 +255,28 @@ AtlasRb::Work.list(in_progress: false, page: 2) # completed deposits, page 2
255
255
  AtlasRb::Work.complete("w-789") # mark w-789 done
256
256
  ```
257
257
 
258
+ A Work carries a second, independent lifecycle state for the other kind of
259
+ failure. `complete` says the deposit finished; `incomplete` says something
260
+ downstream of it gave up — a rendition, the derivatives, the full-text
261
+ extraction — after that job exhausted its retries. Call `mark_incomplete`
262
+ from the give-up handler, and `clear_incomplete` when a later run succeeds,
263
+ which makes the state self-healing.
264
+
265
+ ```ruby
266
+ AtlasRb::Work.mark_incomplete("w-789", reason: "pdf_rendition_gave_up")
267
+ AtlasRb::Work.clear_incomplete("w-789")
268
+ AtlasRb::Work.list(incomplete: true) # the staff repair list
269
+ AtlasRb::Work.list(in_progress: false, incomplete: true) # finished, but degraded
270
+ ```
271
+
272
+ `reason` is a machine token, one per give-up handler. Atlas stores it as an
273
+ opaque string and does not validate it against a list, so the vocabulary is
274
+ yours and a new token needs no Atlas release. Map it to display text at the
275
+ point of use, with a fallback for a token the view has not been taught.
276
+
277
+ The flag never hides the Work. A record with its file, title and metadata but
278
+ one missing derivative is degraded, not broken, and stays readable.
279
+
258
280
  ### Audit-event history
259
281
 
260
282
  `Resource.history` wraps Atlas's `GET /resources/<id>/history` endpoint
@@ -13,10 +13,18 @@ module AtlasRb
13
13
  # @api private
14
14
  ROUTE = "/collections/"
15
15
 
16
- # Hard-delete a Collection.
16
+ # Hard-delete a Collection — a purge, not a withdrawal.
17
17
  #
18
+ # Removes the Collection's metadata, its descriptive-metadata
19
+ # FileSet, and the OCFL objects holding the preserved bytes.
18
20
  # Unrecoverable — prefer {AtlasRb::Collection.tombstone} for
19
- # user-visible withdrawal. Operator-only.
21
+ # user-visible withdrawal. Admin-only.
22
+ #
23
+ # Atlas refuses with a `422` (`has_children`) while the Collection
24
+ # still holds a Work or a sub-container, **including tombstoned
25
+ # ones**. That is stricter than tombstone, which counts only live
26
+ # members: a member left behind by a purge is orphaned for good.
27
+ # Empty the tree leaf-first.
20
28
  #
21
29
  # @param id [String] the Collection ID.
22
30
  # @param confirm [Symbol] must be `:i_understand`. Any other value
@@ -13,10 +13,18 @@ module AtlasRb
13
13
  # @api private
14
14
  ROUTE = "/communities/"
15
15
 
16
- # Hard-delete a Community.
16
+ # Hard-delete a Community — a purge, not a withdrawal.
17
17
  #
18
+ # Removes the Community's metadata, its descriptive-metadata
19
+ # FileSet, and the OCFL objects holding the preserved bytes.
18
20
  # Unrecoverable — prefer {AtlasRb::Community.tombstone} for
19
- # user-visible withdrawal. Operator-only.
21
+ # user-visible withdrawal. Admin-only.
22
+ #
23
+ # Atlas refuses with a `422` (`has_children`) while the Community
24
+ # still holds a Collection or a sub-community, **including
25
+ # tombstoned ones**. That is stricter than tombstone, which counts
26
+ # only live members: a member left behind by a purge is orphaned
27
+ # for good. Empty the tree leaf-first.
20
28
  #
21
29
  # @param id [String] the Community ID.
22
30
  # @param confirm [Symbol] must be `:i_understand`. Any other value
@@ -13,11 +13,16 @@ module AtlasRb
13
13
  # @api private
14
14
  ROUTE = "/works/"
15
15
 
16
- # Hard-delete a Work.
16
+ # Hard-delete a Work — a purge, not a withdrawal.
17
17
  #
18
- # Removes the Work, its FileSets, and their Blobs from Atlas
19
- # storage. Unrecoverable prefer {AtlasRb::Work.tombstone} for
20
- # the user-visible withdrawal path. This is operator-only.
18
+ # Removes the Work's metadata, cascades into its FileSets and their
19
+ # Blobs, and removes the OCFL objects holding the preserved bytes:
20
+ # every retained revision, not only the current one. Nothing
21
+ # survives but the audit row, which records the NOIDs it removed.
22
+ #
23
+ # Unrecoverable — prefer {AtlasRb::Work.tombstone} for the
24
+ # user-visible withdrawal path, which keeps everything and can be
25
+ # reversed. Admin-only.
21
26
  #
22
27
  # @param id [String] the Work ID.
23
28
  # @param confirm [Symbol] must be `:i_understand`. Any other value
data/lib/atlas_rb/blob.rb CHANGED
@@ -186,7 +186,12 @@ module AtlasRb
186
186
  end
187
187
  end
188
188
 
189
- # Delete a Blob (the bytes *and* the metadata record).
189
+ # Delete a Blob: the metadata record **and** the bytes.
190
+ #
191
+ # Atlas removes the whole OCFL object, so **every retained revision** goes,
192
+ # not only the current one — {.versions} and {.rollback} have nothing left
193
+ # to work with afterwards. Unrecoverable, and admin-only. The Blob is also
194
+ # unlinked from its FileSet, whose METS is rebuilt.
190
195
  #
191
196
  # @param id [String] the Blob ID.
192
197
  # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
@@ -101,6 +101,38 @@ module AtlasRb
101
101
  end
102
102
  end
103
103
 
104
+ # Raised when Atlas rejects a Work-association write
105
+ # (`POST` / `DELETE /works/:id/associations...`) with a `422` carrying a
106
+ # machine-readable `error` discriminator — `invalid_type` (not one of the
107
+ # five predicates), `target_not_found`, `invalid_target_type` (the target is
108
+ # not a Work), `self_association`, `tombstoned_work`, or `tombstoned_target`.
109
+ #
110
+ # The association sibling of {LinkedMemberError}; same shape, same rationale
111
+ # (the binding would otherwise discard the envelope on a non-2xx).
112
+ #
113
+ # rescue AtlasRb::WorkAssociationError => e
114
+ # flash.now[:alert] = t("associations.errors.#{e.code}", default: e.message)
115
+ #
116
+ # @note Authorization failures surface as {ForbiddenError} (HTTP 403) —
117
+ # asserting an association is admin / devolved-admin only.
118
+ class WorkAssociationError < Error
119
+ # @return [String, nil] the machine-readable error code from the envelope,
120
+ # suitable for keying an i18n map.
121
+ attr_reader :code
122
+
123
+ # @return [String, nil] the rejected Work's ID, from the envelope.
124
+ attr_reader :resource_id
125
+
126
+ # @param message [String] human-readable rejection description.
127
+ # @param code [String, nil] the envelope's `error` discriminator.
128
+ # @param resource_id [String, nil] the rejected Work's ID.
129
+ def initialize(message, code: nil, resource_id: nil)
130
+ super(message)
131
+ @code = code
132
+ @resource_id = resource_id
133
+ end
134
+ end
135
+
104
136
  # Raised when Atlas rejects a Compilation (Set) write with a `422`
105
137
  # carrying a machine-readable `error` discriminator — a blank title on
106
138
  # create/update (`invalid_record`), or a membership add whose noid does
@@ -84,7 +84,12 @@ module AtlasRb
84
84
  ))["file_set"]
85
85
  end
86
86
 
87
- # Delete a FileSet.
87
+ # Delete a FileSet, its Blobs, and their bytes.
88
+ #
89
+ # Atlas cascades into the FileSet's Blobs and removes each one's OCFL
90
+ # object, so every retained revision of those binaries goes too.
91
+ # Unrecoverable, and admin-only. The parent Work's METS structMap is
92
+ # rebuilt without the removed pages.
88
93
  #
89
94
  # @param id [String] the FileSet ID.
90
95
  # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
@@ -15,7 +15,8 @@ module AtlasRb
15
15
  # {RaiseOnStaleResource}.
16
16
  #
17
17
  # It is intentionally narrow — it only fires on the re-parent
18
- # (`.../parent`) and linked-member (`.../linked_members...`) write paths,
18
+ # (`.../parent`), linked-member (`.../linked_members...`) and association
19
+ # (`.../associations...`) write paths,
19
20
  # the Compilation surface (`/compilations...`), the derivative-permissions
20
21
  # write (`.../derivative_permissions`), the container-create endpoints
21
22
  # ({CREATE_PATHS}), and binary uploads (`/files...`, `/file_sets...`), and
@@ -37,6 +38,7 @@ module AtlasRb
37
38
  # - `403` on a re-parent/linked/Compilation/derivative-permissions/create path → {AtlasRb::ForbiddenError}
38
39
  # - `422` on `.../parent` → {AtlasRb::ReparentError} (`error`/`resource_id`)
39
40
  # - `422` on `.../linked_members...` → {AtlasRb::LinkedMemberError}
41
+ # - `422` on `.../associations...` → {AtlasRb::WorkAssociationError}
40
42
  # - `422` on `/compilations...` → {AtlasRb::CompilationError}
41
43
  # - `422` on `.../derivative_permissions` → {AtlasRb::DerivativePermissionsError}
42
44
  # - `422` + an ACL discriminator anywhere → {AtlasRb::PermissionsError}
@@ -63,6 +65,7 @@ module AtlasRb
63
65
  # @raise [AtlasRb::ForbiddenError] on a 403 to a re-parent/linked/Compilation/create path.
64
66
  # @raise [AtlasRb::ReparentError] on a 422 to a re-parent path.
65
67
  # @raise [AtlasRb::LinkedMemberError] on a 422 to a linked-member path.
68
+ # @raise [AtlasRb::WorkAssociationError] on a 422 to an association path.
66
69
  # @raise [AtlasRb::CompilationError] on a 422 to a Compilation path.
67
70
  # @raise [AtlasRb::DerivativePermissionsError] on a 422 to a derivative-permissions path.
68
71
  # @raise [AtlasRb::PermissionsError] on a 422 carrying an ACL-invariant discriminator.
@@ -74,6 +77,7 @@ module AtlasRb
74
77
  path = env.url&.path.to_s
75
78
  reparent = path.end_with?("/parent")
76
79
  linked = path.include?("/linked_members")
80
+ association = path.include?("/associations")
77
81
  compilation = path.start_with?("/compilations")
78
82
  deriv_perms = path.end_with?("/derivative_permissions")
79
83
  create = env.method.to_s == "post" && CREATE_PATHS.include?(path.chomp("/"))
@@ -92,11 +96,11 @@ module AtlasRb
92
96
  )
93
97
  end
94
98
 
95
- return unless reparent || linked || compilation || deriv_perms || create || upload
99
+ return unless reparent || linked || association || compilation || deriv_perms || create || upload
96
100
 
97
101
  if env.status == 403
98
102
  # 403s on upload paths stay raw — acting-as/authz isn't an upload concern here.
99
- return unless reparent || linked || compilation || deriv_perms || create
103
+ return unless reparent || linked || association || compilation || deriv_perms || create
100
104
 
101
105
  raise AtlasRb::ForbiddenError.new(
102
106
  body["message"] || "Atlas refused the request",
@@ -116,6 +120,12 @@ module AtlasRb
116
120
  code: body["error"],
117
121
  resource_id: body["resource_id"]
118
122
  )
123
+ elsif association
124
+ raise AtlasRb::WorkAssociationError.new(
125
+ body["message"] || "Atlas rejected the association write",
126
+ code: body["error"],
127
+ resource_id: body["resource_id"]
128
+ )
119
129
  elsif compilation
120
130
  raise AtlasRb::CompilationError.new(
121
131
  body["message"] || "Atlas rejected the compilation write",
data/lib/atlas_rb/work.rb CHANGED
@@ -43,6 +43,11 @@ module AtlasRb
43
43
  #
44
44
  # @param in_progress [Boolean, nil] when set, filter to Works whose
45
45
  # `in_progress` flag matches. Omit (or pass `nil`) for "all works".
46
+ # @param incomplete [Boolean, nil] when set, filter to Works whose
47
+ # `incomplete` flag matches — the staff list of Works whose enrichment
48
+ # pipeline gave up (see {.mark_incomplete}). Independent of
49
+ # `in_progress:`, and the two combine: `in_progress: false,
50
+ # incomplete: true` reads as "finished, but degraded".
46
51
  # @param page [Integer, nil] 1-indexed page number.
47
52
  # @param per_page [Integer, nil] page size override.
48
53
  # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
@@ -53,16 +58,20 @@ module AtlasRb
53
58
  # omitted.
54
59
  # @return [AtlasRb::Mash] `{ "works" => [...], "pagination" => {...} }`.
55
60
  # Each entry in `"works"` is a Work summary (`id`, `title`,
56
- # `description`, `in_progress`).
61
+ # `description`, `in_progress`, `incomplete`, `incomplete_reason`).
57
62
  #
58
63
  # @example Find stuck deposits
59
64
  # AtlasRb::Work.list(in_progress: true)
60
65
  #
66
+ # @example Find works whose pipeline gave up
67
+ # AtlasRb::Work.list(incomplete: true)
68
+ #
61
69
  # @example Page through all works
62
70
  # AtlasRb::Work.list(page: 2, per_page: 50)
63
- def self.list(in_progress: nil, page: nil, per_page: nil, nuid: nil, on_behalf_of: nil)
71
+ def self.list(in_progress: nil, incomplete: nil, page: nil, per_page: nil, nuid: nil, on_behalf_of: nil)
64
72
  params = {}
65
73
  params[:in_progress] = in_progress unless in_progress.nil?
74
+ params[:incomplete] = incomplete unless incomplete.nil?
66
75
  params[:page] = page if page
67
76
  params[:per_page] = per_page if per_page
68
77
  AtlasRb::Mash.new(JSON.parse(
@@ -232,6 +241,88 @@ module AtlasRb
232
241
  connection({}, nuid, on_behalf_of: on_behalf_of).post(ROUTE + id + '/complete')
233
242
  end
234
243
 
244
+ # Flag a Work whose enrichment pipeline gave up.
245
+ #
246
+ # The counterpart to {.complete}, for the other half of the lifecycle:
247
+ # `complete` says the deposit finished, this says something downstream of
248
+ # it did not. Call it from a work-scoped job's give-up handler — the PDF or
249
+ # media rendition, the derivatives, the full-text extraction — once that
250
+ # job has exhausted its retries. Atlas's `GET /works?incomplete=true` then
251
+ # lists the Work for staff, and `incomplete_bsi` on its Solr document lets
252
+ # a result row render a pill without a per-row fetch.
253
+ #
254
+ # The flag **never hides** the Work. A record with its file, title and
255
+ # metadata but one missing derivative is degraded, not broken, and stays
256
+ # readable — enrichment does not fail a deposit.
257
+ #
258
+ # Idempotent on the server; the last reason wins. Clear it with
259
+ # {.clear_incomplete} when a later run of the same job succeeds, which
260
+ # makes the state self-healing.
261
+ #
262
+ # @param id [String] the Work ID.
263
+ # @param reason [String, nil] a machine token naming the cause — one per
264
+ # give-up handler, e.g. `"pdf_rendition_gave_up"`,
265
+ # `"media_rendition_gave_up"`, `"ingest_gave_up"`. Atlas stores it as an
266
+ # opaque string and does **not** validate it against a list, so the
267
+ # vocabulary is the caller's and a new token needs no Atlas release. Map
268
+ # it to display text at the point of use, with a fallback for a token the
269
+ # view has not been taught. A blank reason still sets the flag.
270
+ # @param nuid [String, nil] optional NUID of the acting user.
271
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
272
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
273
+ # omitted.
274
+ # @return [Hash] the updated Work, the same shape {.find} returns, carrying
275
+ # `incomplete` and `incomplete_reason`.
276
+ # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
277
+ # conflict that exhausted its internal retry budget (HTTP 409 with
278
+ # `error: "stale_resource"`).
279
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
280
+ # resource, so the write did not happen.
281
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
282
+ # and body.
283
+ #
284
+ # @example In a give-up handler
285
+ # AtlasRb::Work.mark_incomplete(work_id, reason: "pdf_rendition_gave_up")
286
+ def self.mark_incomplete(id, reason:, nuid: nil, on_behalf_of: nil)
287
+ AtlasRb::Mash.new(write_resource(
288
+ connection({}, nuid, on_behalf_of: on_behalf_of)
289
+ .post(ROUTE + id + '/incomplete', JSON.dump(reason: reason))
290
+ ))["work"]
291
+ end
292
+
293
+ # Clear the incomplete flag and its reason.
294
+ #
295
+ # The repair half of {.mark_incomplete}: call it from the same job when a
296
+ # later run succeeds, or by hand once an operator has fixed the Work. Both
297
+ # fields clear together — a reason without a flag would leave a stale cause
298
+ # on the Solr document.
299
+ #
300
+ # Idempotent: clearing a Work that was never flagged is a no-op.
301
+ #
302
+ # @param id [String] the Work ID.
303
+ # @param nuid [String, nil] optional NUID of the acting user.
304
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
305
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
306
+ # omitted.
307
+ # @return [Hash] the updated Work, the same shape {.find} returns, with
308
+ # `incomplete` false and `incomplete_reason` null.
309
+ # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
310
+ # conflict that exhausted its internal retry budget (HTTP 409 with
311
+ # `error: "stale_resource"`).
312
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no such
313
+ # resource, so the write did not happen.
314
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's status
315
+ # and body.
316
+ #
317
+ # @example On a later successful run
318
+ # AtlasRb::Work.clear_incomplete(work_id)
319
+ def self.clear_incomplete(id, nuid: nil, on_behalf_of: nil)
320
+ AtlasRb::Mash.new(write_resource(
321
+ connection({}, nuid, on_behalf_of: on_behalf_of)
322
+ .delete(ROUTE + id + '/incomplete')
323
+ ))["work"]
324
+ end
325
+
235
326
  # Replace a Work's metadata by uploading a MODS XML document.
236
327
  #
237
328
  # @param id [String] the Work ID.
@@ -708,5 +799,128 @@ module AtlasRb
708
799
  .delete(ROUTE + work_id + '/linked_members/' + collection_id)
709
800
  )
710
801
  end
802
+
803
+ # The five relationship predicates Atlas accepts, carried here so a caller
804
+ # can build a select box without hard-coding the vocabulary. Adding a sixth
805
+ # needs an Atlas release, so this list cannot drift ahead of the server.
806
+ ASSOCIATION_TYPES = %w[
807
+ is_codebook_for
808
+ is_figure_for
809
+ is_instructional_material_for
810
+ is_supplemental_material_for
811
+ is_transcription_of
812
+ ].freeze
813
+
814
+ # List a Work's typed associations with other Works.
815
+ #
816
+ # Wraps `GET /works/<id>/associations`. An association is DRS v1's
817
+ # "associated works": a directed claim that one object is the codebook,
818
+ # figure, transcription, instructional material or supplemental material
819
+ # **for** another. Both objects stay separate records — this is not
820
+ # membership, and nothing moves in the containment tree.
821
+ #
822
+ # The edge is stored once, on the Work that asserts it. Atlas derives the
823
+ # other direction, so `outbound` and `inbound` can never disagree.
824
+ #
825
+ # @param id [String] the Work ID.
826
+ # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
827
+ # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
828
+ # path it is ignored (identity lives in the token).
829
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
830
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
831
+ # omitted.
832
+ # @return [Hash] `{"outbound" => {predicate => [noid, …]}, "inbound" => {…}}`.
833
+ # `outbound` is what this Work asserts, `inbound` what other Works assert
834
+ # about it. Predicates holding no edges are omitted, so both maps are
835
+ # `{}` for an unassociated Work.
836
+ #
837
+ # @example
838
+ # AtlasRb::Work.associations("w-789")
839
+ # # => {"outbound" => {"is_codebook_for" => ["w-123"]}, "inbound" => {}}
840
+ def self.associations(id, nuid: nil, on_behalf_of: nil)
841
+ JSON.parse(
842
+ connection({}, nuid, on_behalf_of: on_behalf_of).get(ROUTE + id + '/associations')&.body
843
+ )
844
+ end
845
+
846
+ # Assert that this Work stands in a typed relationship to another Work.
847
+ #
848
+ # Wraps `POST /works/<id>/associations` with a `work_id` + `type` body.
849
+ # The edge is stored on **this** Work only; `target` reports the same edge
850
+ # under `inbound`. Asserting an edge that already exists is a no-op, and
851
+ # two Works can hold several different edges at once.
852
+ #
853
+ # A cycle is permitted and meaningful — "A is a transcription of B" and
854
+ # "B is a figure for A" can both be true.
855
+ #
856
+ # @param work_id [String] the asserting Work's ID (the codebook, figure, …).
857
+ # @param target_id [String] the Work being pointed at (the dataset, article, …).
858
+ # @param type [String] one of {ASSOCIATION_TYPES}.
859
+ # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
860
+ # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
861
+ # path it is ignored (identity lives in the token).
862
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
863
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
864
+ # omitted.
865
+ # @return [Hash] the Work's associations *after* the add, in the
866
+ # {.associations} shape — so no follow-up GET is needed.
867
+ # @raise [AtlasRb::WorkAssociationError] if Atlas rejects the claim (HTTP
868
+ # 422): unknown type, unresolvable target, a non-Work target, the Work
869
+ # itself, or either end tombstoned. The envelope's `error` code is
870
+ # exposed as `#code`.
871
+ # @raise [AtlasRb::ForbiddenError] if Atlas refuses the write (HTTP 403).
872
+ # Associating is admin / devolved-admin only, because the claim renders
873
+ # on the target's page too.
874
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no
875
+ # such Work, so the write did not happen.
876
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's
877
+ # status and body.
878
+ #
879
+ # @example
880
+ # AtlasRb::Work.associate("w-789", "w-123", type: "is_codebook_for")
881
+ # # => {"outbound" => {"is_codebook_for" => ["w-123"]}, "inbound" => {}}
882
+ def self.associate(work_id, target_id, type:, nuid: nil, on_behalf_of: nil)
883
+ write_resource(
884
+ connection({ work_id: target_id, type: type }, nuid, on_behalf_of: on_behalf_of)
885
+ .post(ROUTE + work_id + '/associations')
886
+ )
887
+ end
888
+
889
+ # Retract one typed relationship between two Works.
890
+ #
891
+ # Wraps `DELETE /works/<id>/associations/<type>/<target_id>` — the type is
892
+ # a path segment because it is part of the edge's identity, so retracting
893
+ # the figure claim leaves a transcription claim between the same two Works
894
+ # standing. Idempotent: retracting an edge that was never asserted is a
895
+ # no-op.
896
+ #
897
+ # @param work_id [String] the asserting Work's ID.
898
+ # @param target_id [String] the associated Work to drop.
899
+ # @param type [String] the predicate to retract, one of {ASSOCIATION_TYPES}.
900
+ # @param nuid [String, nil] optional acting user's NUID. On the relay-signing
901
+ # path it is signed into the assertion `sub`; on the BYO-JWT (`ATLAS_JWT`)
902
+ # path it is ignored (identity lives in the token).
903
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
904
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
905
+ # omitted.
906
+ # @return [Hash] the Work's remaining associations, in the {.associations}
907
+ # shape.
908
+ # @raise [AtlasRb::WorkAssociationError] if Atlas rejects the write (HTTP
909
+ # 422) — an unresolvable target is the usual cause here.
910
+ # @raise [AtlasRb::ForbiddenError] if Atlas refuses the write (HTTP 403).
911
+ # @raise [AtlasRb::NotFoundError] if Atlas answers `404` — the id names no
912
+ # such Work, so the write did not happen.
913
+ # @raise [AtlasRb::ResourceError] on any other non-2xx, carrying Atlas's
914
+ # status and body.
915
+ #
916
+ # @example
917
+ # AtlasRb::Work.disassociate("w-789", "w-123", type: "is_codebook_for")
918
+ # # => {"outbound" => {}, "inbound" => {}}
919
+ def self.disassociate(work_id, target_id, type:, nuid: nil, on_behalf_of: nil)
920
+ write_resource(
921
+ connection({}, nuid, on_behalf_of: on_behalf_of)
922
+ .delete(ROUTE + work_id + '/associations/' + type.to_s + '/' + target_id)
923
+ )
924
+ end
711
925
  end
712
926
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atlas_rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.10.0
4
+ version: 1.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-04 00:00:00.000000000 Z
11
+ date: 2026-08-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday