jbr 3.8.0 → 3.10.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: de01eb560b5d6366142d35901817eae421a30ccebf31aeede4cacb2e97720cd3
4
- data.tar.gz: 1a613711f19080da2368d9b2d38d2e9fff372dbd4e37126b2bc06e9d4c9a9493
3
+ metadata.gz: 5a315b6ba52c573f0d7fa1a0adec8145578550bd67a136071e7b8a02ea328ea1
4
+ data.tar.gz: 8c338c2f5a13ce4e98c99c2b5c11377274c71908a2b93fc6c114d8fb109ae540
5
5
  SHA512:
6
- metadata.gz: 9b8fc6971e3cbe48eecf5025cb7f5b9beb9e7490a0b7a6830b0a22eb9b4c98fbe3c066686866dff576de01bfc0e10f16050c6bd1002b929bd18940dfeeccd137
7
- data.tar.gz: 2cbdcb7fd7bd995bf7450784afdb08d59acce2c0ea788f3192a1c62aabaf00251497bfb8ff6efa57a932d96a15735dbaa9bdc4e846824679521056e7a3129e33
6
+ metadata.gz: e82a0f7dac54170d4f791a752f7497d0b783dce98f4057c606c3ee1f5fe73c85ef32032eb297e2406b95d82bf531d6ba1cf2f4db8a9de5be1fbef38ac1c1c69b
7
+ data.tar.gz: 82a90ec3316204bb8ccf87c4d4011e249cd7b5d4484697fc30477f553bd4146baa9402bff10f4c6c2f893ce93bc7d06509831ecaf4a7d8a16a60dfad5b0ae67d
data/README.md CHANGED
@@ -111,15 +111,19 @@ job.scheduled_at # => 2026-05-14 23:02:52
111
111
  job.completed_at # => 2026-05-18 11:36:13
112
112
  ```
113
113
 
114
- Or walk the account's jobs, oldest first. Jobber is asked for a page at a time, and only
115
- once the page before it runs out, so `first` costs one request where `to_a` costs as many
116
- as the account has pages. A walk paces itself, so a long one is never refused: see
117
- [Rate limits](#rate-limits).
114
+ Or walk the account's jobs, oldest first. Either half of the schedule takes how much of it
115
+ you meant a duration, measured from the same now the half is split at and a walk that
116
+ stops at a boundary reads only the pages up to it. Jobber is asked for a page at a time, and
117
+ only once the page before it runs out, so `first` costs one request where `to_a` costs as many
118
+ as the account has pages. A walk is priced by what its pages carry, and a long one can be
119
+ refused for it: see [Rate limits](#rate-limits).
118
120
 
119
121
  ```ruby
120
122
  jobs = oauth.jobs # => an Enumerable of every job, nothing fetched yet
121
- oauth.jobs.past # => an Enumerator of the ones dated before now
122
- oauth.jobs.upcoming # => an Enumerator of the ones dated from now on
123
+ oauth.jobs.past # => the ones dated before now, nothing fetched yet
124
+ oauth.jobs.upcoming # => the ones dated from now on, nothing fetched yet
125
+ oauth.jobs.past(1.year) # => only as far back as a year, which is fewer pages to read
126
+ oauth.jobs.past.ids # => %w[Z2lkOi8vS ...], every page of them, and nothing else about them
123
127
 
124
128
  job = jobs.first
125
129
  job.name # => 'Furnace tune-up', or the job's ID where nobody titled it. Never nil or empty
@@ -175,8 +179,10 @@ Walk the account's visits, oldest first, the same way as its jobs:
175
179
 
176
180
  ```ruby
177
181
  visits = oauth.visits # => an Enumerable of every visit, nothing fetched yet
178
- oauth.visits.upcoming # => an Enumerator of the ones dated from now on
179
- oauth.visits.past # => an Enumerator of the ones dated before now
182
+ oauth.visits.upcoming # => the ones dated from now on, nothing fetched yet
183
+ oauth.visits.past # => the ones dated before now, nothing fetched yet
184
+ oauth.visits.upcoming(3.months) # => only as far ahead as three months
185
+ oauth.visits.upcoming.ids # => %w[Z2lkOi8vS ...], every page of them, and nothing else
180
186
 
181
187
  visit = visits.first
182
188
  visit.id # => 'Z2lkOi8vS'
@@ -247,6 +253,15 @@ holding a transaction open.
247
253
  Every connection the gem asks for is bounded, to keep a query on the affordable side of that:
248
254
  twenty lines to a job, and twenty jobs or visits to a page.
249
255
 
256
+ `ids` is the cheap way to walk an account. It asks for the ID and nothing else, which prices
257
+ a row at a fraction of a record and buys a hundred of them to a page — so five times the
258
+ account is read for a fraction of the budget. Reach for it where each record is then read on
259
+ its own with `find`, one background job at a time:
260
+
261
+ ```ruby
262
+ oauth.jobs.past.ids.each { |id| ImportJob.perform_later id }
263
+ ```
264
+
250
265
  ### Events
251
266
 
252
267
  Parse the payload of a Jobber event webhook:
data/lib/jbr/jobs.rb CHANGED
@@ -1,23 +1,13 @@
1
1
  module Jbr
2
2
  # The jobs on a Jobber account, oldest first, walked a page at a time.
3
3
  class Jobs < Resource
4
- include Enumerable, Includable
4
+ include Includable, Listable
5
5
 
6
6
  # What a job answers with wherever one is read, before anything it was asked to bring
7
7
  # back with it.
8
8
  FIELDS = 'id title instructions jobStatus total createdAt startAt completedAt ' \
9
9
  'quote { id amounts { total } }'
10
10
 
11
- # Every job on the account, past and future alike. Nothing is read until the walk starts,
12
- # and a page is read only once the one before it runs out.
13
- def each(&) = walk.each(&)
14
-
15
- # @return [Enumerator<Job>] the jobs scheduled from now on.
16
- def upcoming = walk from_now
17
-
18
- # @return [Enumerator<Job>] the jobs that started before now.
19
- def past = walk until_now
20
-
21
11
  # Shadows Enumerable#find on purpose, the way Active Record does: a job is reached by the
22
12
  # ID Jobber files it under, not by asking every job on the account whether it is the one.
23
13
  # @param id [String] the Jobber ID of the job.
@@ -29,20 +19,7 @@ module Jbr
29
19
 
30
20
  private
31
21
 
32
- # Twenty a page, not forty and not a hundred: Jobber prices a query by its page size, and
33
- # what an includes brings back is charged for on top of every row of it — so a page of jobs
34
- # carrying their lines, their property and its client priced past what a bucket holds. Half
35
- # the page costs half the query and loses nothing, since a walk simply reads more pages.
36
- def page
37
- <<~GRAPHQL
38
- query($after: String, $filter: JobFilterAttributes) {
39
- jobs(first: 20, after: $after, filter: $filter) {
40
- nodes { #{FIELDS} #{selections} }
41
- pageInfo { hasNextPage endCursor }
42
- }
43
- }
44
- GRAPHQL
45
- end
22
+ def page = paged "#{FIELDS} #{selections}", PAGE
46
23
 
47
24
  def one
48
25
  <<~GRAPHQL
@@ -54,6 +31,8 @@ module Jbr
54
31
 
55
32
  def field = 'jobs'
56
33
 
34
+ def filtered = 'JobFilterAttributes'
35
+
57
36
  def item(node) = Job.new node: node
58
37
  end
59
38
  end
@@ -0,0 +1,96 @@
1
+ module Jbr
2
+ # What a list of records answers, and how much of an account it costs to ask. Jobber prices
3
+ # a query by the page it asks for and by what every row of that page carries, so a list is
4
+ # read either as records or as the IDs alone, each with a page sized to what it carries.
5
+ module Listable
6
+ include Enumerable
7
+
8
+ # Records a page, not forty and not a hundred: what an includes brings back is charged for
9
+ # on top of every row of it, so a page of jobs carrying their lines, their property and its
10
+ # client priced past what a bucket holds. Half the page costs half the query and loses
11
+ # nothing, since a walk simply reads more pages.
12
+ PAGE = 20
13
+
14
+ # IDs a page, which the same budget affords five times over where a row carries one field
15
+ # and no nesting at all.
16
+ IDS_PAGE = 100
17
+
18
+ # Every record the list is narrowed to, oldest first. Nothing is read until the walk
19
+ # starts, and a page is read only once the one before it runs out.
20
+ def each(&) = walk(page).each(&)
21
+
22
+ # @param within [ActiveSupport::Duration, Numeric, nil] how far ahead to look, or nil for
23
+ # as far ahead as the account is scheduled.
24
+ # @return [Listable] the same list, narrowed to what is scheduled from now on.
25
+ def upcoming(within = nil)
26
+ now = Time.now
27
+ narrowed after: now, before: (now + within if within)
28
+ end
29
+
30
+ # @param within [ActiveSupport::Duration, Numeric, nil] how far back to look, or nil for
31
+ # as far back as the account goes.
32
+ # @return [Listable] the same list, narrowed to what started before now.
33
+ def past(within = nil)
34
+ now = Time.now
35
+ narrowed before: now, after: (now - within if within)
36
+ end
37
+
38
+ # The ID Jobber files each record under, and nothing else about it: the cheapest question
39
+ # an account can be walked with, and the one to ask where every record is then read on its
40
+ # own through {#find}.
41
+ # @return [Array<String>] every ID in the list, every page of them read.
42
+ def ids = walk(ids_page).map(&:id)
43
+
44
+ private
45
+
46
+ # The two halves of a schedule, split at the moment they are asked for rather than per
47
+ # page: read page by page the boundary would slide, and something could cross it unseen.
48
+ # The same moment is the near end of a window, so the far end is measured from it too.
49
+ def narrowed(after: nil, before: nil)
50
+ bounds = { after: after&.iso8601, before: before&.iso8601 }.compact
51
+ self.class.new oauth: @oauth, includes: @includes, filter: { startAt: bounds }
52
+ end
53
+
54
+ # Whether a moment falls in the stretch of the schedule the list was narrowed to, for
55
+ # anything answering one without asking Jobber. A record with no moment at all is in an
56
+ # open-ended upcoming list, since nothing has started it, and in no window, since a window
57
+ # is a stretch it would have to have fallen in.
58
+ def scheduled?(at)
59
+ return true unless @filter
60
+
61
+ after, before = @filter[:startAt].values_at :after, :before
62
+ return before.nil? unless at
63
+
64
+ (after.nil? || at >= Time.iso8601(after)) && (before.nil? || at <= Time.iso8601(before))
65
+ end
66
+
67
+ # Every record a paged query answers, one at a time, a page read only once the one before
68
+ # it runs out. The filter is data: narrowed to nothing, the query narrows nothing.
69
+ def walk(statement)
70
+ Enumerator.new do |yielder|
71
+ after = nil
72
+ loop do
73
+ answered = @oauth.query statement, variables: { after: after, filter: @filter }.compact
74
+ current = answered.fetch field, {}
75
+ current.fetch('nodes', []).each { |node| yielder << item(node) }
76
+ break unless current.dig 'pageInfo', 'hasNextPage'
77
+
78
+ after = current.dig 'pageInfo', 'endCursor'
79
+ end
80
+ end
81
+ end
82
+
83
+ def ids_page = paged 'id', IDS_PAGE
84
+
85
+ def paged(fields, size)
86
+ <<~GRAPHQL
87
+ query($after: String, $filter: #{filtered}) {
88
+ #{field}(first: #{size}, after: $after, filter: $filter) {
89
+ nodes { #{fields} }
90
+ pageInfo { hasNextPage endCursor }
91
+ }
92
+ }
93
+ GRAPHQL
94
+ end
95
+ end
96
+ end
data/lib/jbr/mock/jobs.rb CHANGED
@@ -1,27 +1,19 @@
1
1
  module Jbr
2
- # The jobs an app under test asked {Jbr.mock} to answer with.
2
+ # The jobs an app under test asked {Jbr.mock} to answer with. Only the walk is mocked:
3
+ # narrowing a list, and reading it as records or as IDs, is the same code a real one runs.
3
4
  class Mock::Jobs < Jobs
4
5
  # The job filed under that ID where the app listed one, and otherwise the single job it
5
6
  # named — which is every app that mocks a lookup without mocking a list.
6
7
  # @return [Mock::Job] the job asked for.
7
8
  def find(id) = Mock::Job.new node: listed(id) || Jbr.mock.job
8
9
 
9
- # @return [Enumerator<Mock::Job>] every job the app named.
10
- def each(&) = mocked(Jbr.mock.jobs).each(&)
11
-
12
- # @return [Enumerator<Mock::Job>] those it dated from now on, and any it left undated.
13
- def upcoming = mocked Jbr.mock.jobs.reject { |job| started? job }
14
-
15
- # @return [Enumerator<Mock::Job>] those it dated before now.
16
- def past = mocked Jbr.mock.jobs.select { |job| started? job }
17
-
18
10
  private
19
11
 
20
- def mocked(jobs)
21
- Enumerator.new { |yielder| jobs.each { |job| yielder << Mock::Job.new(node: job) } }
12
+ def walk(_statement)
13
+ Enumerator.new { |yielder| selected.each { |job| yielder << Mock::Job.new(node: job) } }
22
14
  end
23
15
 
24
- def started?(job) = job[:scheduled_at] ? job[:scheduled_at] <= Time.now : false
16
+ def selected = Jbr.mock.jobs.select { |job| scheduled? job[:scheduled_at] }
25
17
 
26
18
  # Only a real list is looked through: an app that mocked the list as something raising
27
19
  # was mocking the walk failing, and a lookup is a question of its own.
@@ -1,25 +1,17 @@
1
1
  module Jbr
2
- # The visits an app under test asked {Jbr.mock} to answer with.
2
+ # The visits an app under test asked {Jbr.mock} to answer with. Only the walk is mocked:
3
+ # narrowing a list, and reading it as records or as IDs, is the same code a real one runs.
3
4
  class Mock::Visits < Visits
4
- # @return [Enumerator<Mock::Visit>] every visit the app named.
5
- def each(&) = mocked(Jbr.mock.visits).each(&)
6
-
7
- # @return [Enumerator<Mock::Visit>] those it dated from now on, and any it left undated.
8
- def upcoming = mocked Jbr.mock.visits.reject { |visit| started? visit }
9
-
10
- # @return [Enumerator<Mock::Visit>] those it dated before now.
11
- def past = mocked Jbr.mock.visits.select { |visit| started? visit }
12
-
13
5
  # @return [Mock::Visit] the visit the app listed under that ID.
14
6
  def find(id) = Mock::Visit.new node: listed(id)
15
7
 
16
8
  private
17
9
 
18
- def mocked(visits)
19
- Enumerator.new { |yielder| visits.each { |visit| yielder << Mock::Visit.new(node: visit) } }
10
+ def walk(_statement)
11
+ Enumerator.new { |yielder| selected.each { |it| yielder << Mock::Visit.new(node: it) } }
20
12
  end
21
13
 
22
- def started?(visit) = visit[:starts_at] ? visit[:starts_at] <= Time.now : false
14
+ def selected = Jbr.mock.visits.select { |visit| scheduled? visit[:starts_at] }
23
15
 
24
16
  def listed(id) = Jbr.mock.visits.to_a.find { |visit| visit[:id] == id }.to_h
25
17
  end
data/lib/jbr/resource.rb CHANGED
@@ -1,15 +1,17 @@
1
1
  module Jbr
2
2
  # What every Jobber resource shares: the node Jobber answered with, the credentials it was
3
- # read through, and the paging that every list of them arrives in.
3
+ # read through, and what a list of them was asked and narrowed to.
4
4
  class Resource
5
5
  # @param oauth [OAuth] the credentials to reach Jobber with, where reaching it is needed.
6
6
  # @param node [Hash] the record as Jobber answered it.
7
7
  # @param includes [Hash] what a list was asked to bring back beside its records.
8
- def initialize(oauth: nil, node: {}, includes: {})
8
+ # @param filter [Hash] what a list was narrowed to, in the shape Jobber filters by.
9
+ def initialize(oauth: nil, node: {}, includes: {}, filter: nil)
9
10
  @oauth = oauth
10
11
  @node = node
11
12
  @id = node['id']
12
13
  @includes = includes
14
+ @filter = filter
13
15
  end
14
16
 
15
17
  # @return [String, nil] the Jobber ID, from the node it came in or once one was created.
@@ -20,27 +22,5 @@ module Jbr
20
22
  # @return [Time, nil] what Jobber answered under a key, as a time. An empty answer is no
21
23
  # answer: Time.iso8601 raises on one, where nothing at all it simply has none of.
22
24
  def time(key) = (Time.iso8601 @node[key] if @node[key].present?)
23
-
24
- # Every item a paged query answers, one at a time, a page read only once the one before
25
- # it runs out. The filter is data: handed none, the query narrows nothing.
26
- def walk(filter = nil)
27
- Enumerator.new do |yielder|
28
- after = nil
29
- loop do
30
- answered = @oauth.query(page, variables: { after: after, filter: filter }.compact)
31
- current = answered.fetch field, {}
32
- current.fetch('nodes', []).each { |node| yielder << item(node) }
33
- break unless current.dig 'pageInfo', 'hasNextPage'
34
-
35
- after = current.dig 'pageInfo', 'endCursor'
36
- end
37
- end
38
- end
39
-
40
- # The two halves of a schedule, split at the moment they are asked for rather than per
41
- # page: read page by page the boundary would slide, and something could cross it unseen.
42
- def from_now = { startAt: { after: Time.now.iso8601 } }
43
-
44
- def until_now = { startAt: { before: Time.now.iso8601 } }
45
25
  end
46
26
  end
data/lib/jbr/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # A Ruby client for the Jobber API.
2
2
  module Jbr
3
3
  # The version of this gem.
4
- VERSION = '3.8.0'
4
+ VERSION = '3.10.0'
5
5
  end
data/lib/jbr/visits.rb CHANGED
@@ -1,21 +1,11 @@
1
1
  module Jbr
2
2
  # The visits on a Jobber account, oldest first, walked a page at a time.
3
3
  class Visits < Resource
4
- include Enumerable, Includable
4
+ include Includable, Listable
5
5
 
6
6
  # What a visit answers with, before anything it was asked to bring back with it.
7
7
  FIELDS = 'id title startAt endAt allDay clientConfirmed job { id }'
8
8
 
9
- # Every visit on the account, past and future alike. Nothing is read until the walk
10
- # starts, and a page is read only once the one before it runs out.
11
- def each(&) = walk.each(&)
12
-
13
- # @return [Enumerator<Visit>] the visits scheduled from now on.
14
- def upcoming = walk from_now
15
-
16
- # @return [Enumerator<Visit>] the visits that started before now.
17
- def past = walk until_now
18
-
19
9
  # Shadows Enumerable#find on purpose, the way jobs do: a visit is reached by the ID Jobber
20
10
  # files it under, not by asking every visit on the account whether it is the one.
21
11
  # @param id [String] the Jobber ID of the visit.
@@ -27,6 +17,8 @@ module Jbr
27
17
 
28
18
  private
29
19
 
20
+ def page = paged "#{FIELDS} #{selections}", PAGE
21
+
30
22
  def one
31
23
  <<~GRAPHQL
32
24
  query($id: EncodedId!) {
@@ -35,21 +27,10 @@ module Jbr
35
27
  GRAPHQL
36
28
  end
37
29
 
38
- # Twenty a page, the same as jobs: Jobber prices a query by its page size, and what an
39
- # includes brings back is charged for on top of every row of it.
40
- def page
41
- <<~GRAPHQL
42
- query($after: String, $filter: VisitFilterAttributes) {
43
- visits(first: 20, after: $after, filter: $filter) {
44
- nodes { #{FIELDS} #{selections} }
45
- pageInfo { hasNextPage endCursor }
46
- }
47
- }
48
- GRAPHQL
49
- end
50
-
51
30
  def field = 'visits'
52
31
 
32
+ def filtered = 'VisitFilterAttributes'
33
+
53
34
  def item(node) = Visit.new node: node
54
35
  end
55
36
  end
data/lib/jbr.rb CHANGED
@@ -36,6 +36,7 @@ require 'jbr/account'
36
36
  require 'jbr/property'
37
37
  require 'jbr/properted'
38
38
  require 'jbr/includable'
39
+ require 'jbr/listable'
39
40
  # LineItem before Itemized, and both before Job: the lines a job is made of are asked for
40
41
  # by a constant the include reads as it loads.
41
42
  require 'jbr/line_item'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jbr
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.8.0
4
+ version: 3.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -120,6 +120,7 @@ files:
120
120
  - lib/jbr/job.rb
121
121
  - lib/jbr/jobs.rb
122
122
  - lib/jbr/line_item.rb
123
+ - lib/jbr/listable.rb
123
124
  - lib/jbr/mock.rb
124
125
  - lib/jbr/mock/account.rb
125
126
  - lib/jbr/mock/client.rb