atlas_rb 1.10.1 → 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: 6d8edc13167cb16fb5482d6a5cd19760bf323f67bf1a23f00a9508f8320bf798
4
- data.tar.gz: e86459ce460b8eb30cffcfaaed75b06c750649aa57559e33d5ea277ce0bd2d8c
3
+ metadata.gz: 58d8d5524e43dc1f14f172ea65bc1e78e55826a63c814f2ef517675d07732d08
4
+ data.tar.gz: 548d4cba38f7a2ef81f210612157ee36a56de6e15b5fcff367b4df1b73fda847
5
5
  SHA512:
6
- metadata.gz: 06a66bf273c655ae075c821d158555139094714e3b5d0f3ca15296657afe067267ef781190c4804d5a97d3576237a11d664141f28256702df4151fefde515255
7
- data.tar.gz: dfd113525542142a75e0789a5e9ed34424485e1ac69359f5e4c505e24d3752e9eda54e756804f13f195588f6079d0c828426e0a5b937d8ad53c2ef8708eaff00
6
+ metadata.gz: 745c494de1bd2da2a0048e2d3d562961bed6e48058b30da25dcbcdc0f9b2b7f3159c2b831508ac972a968dc6f02ee2a7f0a5cf11b85e2319277d1f200adf51f4
7
+ data.tar.gz: fe90db7467cdae4726357c2ba6d0d939d50c4174f1a3aa43ee8fd91adea19923fa321f53804fb52ed8db39eab22b578dadc6e542f77d54d1e1177e1effab0c53
data/.version CHANGED
@@ -1 +1 @@
1
- 1.10.1
1
+ 1.11.0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
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
+
3
61
  ## 1.10.1
4
62
 
5
63
  ### Added — `Work.mark_incomplete` / `Work.clear_incomplete`
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.10.1)
4
+ atlas_rb (1.11.0)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
@@ -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
@@ -799,5 +799,128 @@ module AtlasRb
799
799
  .delete(ROUTE + work_id + '/linked_members/' + collection_id)
800
800
  )
801
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
802
925
  end
803
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.1
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-06 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