sequel-notion 0.2.0 → 0.2.1

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: 8a7f11227df00a9112d8b47f760985a0290807b89d7224d9e9fce4c93df7d7c9
4
- data.tar.gz: 30ce794fb6a0d9fe33ac45790319539f8d0d63b2d0e8df5cce10367a566ad39a
3
+ metadata.gz: c1fb73a833c0fc96c70528e504873f4765efec4ccddc4737f6eedc8df3723e60
4
+ data.tar.gz: 0e2feb6f99c68194d3e7964d3ae16c36c63887ca2064a299b2ba46469cf5da8f
5
5
  SHA512:
6
- metadata.gz: ccc702b83af21366469e911884ba787dc56d6bc94c6e186a7cf495f695c773f9830d4f98a53ed061f6e2f95ef2078be9f4a6554768fec336cece00179d3d363a
7
- data.tar.gz: 27003c0414d36f7e0088eddafb1e4dfc1a8dc568bf121bc259685097565e66fb9029def065ac11afeaeca3ccea120b824e0467538191d697a24e36c4d075bb95
6
+ metadata.gz: d280403f620b4c64ba88a46e2b18963c175464c0545d27026a9dfe05a20b4dd527bf8368d25386a5b4cb409fa4bbaffbc8cb0a6fb3e14c9101a1d33719c0c8cf
7
+ data.tar.gz: f2113de1c055a39cc0f77755447c520a36cb615e9e5bc9a1652a9d6c0394fd7df5e3da99d3971d4a85a094385626b0fe893a6429212e7dee54ced3b785d0e3de
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 — 2026-10-22
4
+
5
+ ### Added
6
+
7
+ - `DB.upload_file(data, filename:, content_type:)` uploads a file and
8
+ returns its id; `File.uploaded(id, name:)` writes it to a files
9
+ property (checked live).
10
+ - `DB.request` is documented as the way to reach endpoints the adapter
11
+ does not map (blocks, comments), with its retries and errors.
12
+
13
+ ### Changed
14
+
15
+ - Failed connections (refused, reset, unresolved, connect timeout) are
16
+ retried like timeouts; page creation and a file upload's send only
17
+ when the connection never opened.
18
+
3
19
  ## 0.2.0 — 2026-10-10
4
20
 
5
21
  ### Added
data/README.md CHANGED
@@ -22,6 +22,7 @@ else raises a `Sequel::Error` rather than be dropped.
22
22
  - [Writing](#writing)
23
23
  - [Models](#models)
24
24
  - [Schema](#schema)
25
+ - [Raw requests](#raw-requests)
25
26
  - [Errors, retries and logging](#errors-retries-and-logging)
26
27
  - [Known shortfalls](#known-shortfalls)
27
28
  - [Checked against the live API](#checked-against-the-live-api)
@@ -339,6 +340,27 @@ writable columns in schema order.
339
340
  `update` and `delete` first collect the matching page ids, then send one
340
341
  request per page.
341
342
 
343
+ ### Uploading files
344
+
345
+ `DB.upload_file` uploads bytes to Notion and returns the file upload's
346
+ id; `File.uploaded` turns that id into a value for a files property:
347
+
348
+ ```ruby
349
+ id = DB.upload_file(File.binread("q.pdf"), filename: "q.pdf",
350
+ content_type: "application/pdf")
351
+ DB[:leases].where(id: page).update(Docs: Sequel::Notion::File.uploaded(id))
352
+ ```
353
+
354
+ It takes the file's bytes, as a `String` or anything with `#read` (a
355
+ `String` is the content, never a path: `upload_file("q.pdf", …)`
356
+ uploads the five bytes `q.pdf`), and sends them in one part
357
+ (Notion's `single_part` mode; `multi_part` is not supported). The upload
358
+ reads back as a Notion-hosted file. Notion keeps the upload's file name
359
+ in ASCII only (`reçu été "q".pdf` became `reu_t_q.pdf`);
360
+ `File.uploaded(id, name: "reçu.pdf")` names the file as given. An upload
361
+ that is never attached carries an `expiry_time` an hour away. The id can also go into a file block
362
+ through [raw requests](#raw-requests).
363
+
342
364
 
343
365
  ## Models
344
366
 
@@ -385,15 +407,47 @@ in Notion; every name of that data source (an alias, its id) sees the
385
407
  change.
386
408
 
387
409
 
410
+ ## Raw requests
411
+
412
+ `DB.request(verb, path, data = nil)` sends one request to any JSON
413
+ endpoint the adapter does not cover (blocks, comments, users), through
414
+ the same connection, retries, errors and logging as every query:
415
+
416
+ ```ruby
417
+ DB.request(:get, "blocks/#{page}/children", page_size: 100)
418
+ DB.request(:patch, "blocks/#{page}/children", children: [block])
419
+ DB.request(:delete, "blocks/#{block_id}")
420
+ ```
421
+
422
+ - `path` is relative to `https://api.notion.com/v1`; the version sent is
423
+ `Sequel::Notion::API_VERSION`.
424
+ - `data` is the query string for `:get` and `:delete`, and the JSON body
425
+ for `:post` and `:patch`.
426
+ - It returns the parsed JSON body, a `Hash`; a response that is not
427
+ typed JSON comes back as its `String`.
428
+ - Errors and retries are those of the next section. Every POST but page
429
+ creation and a file upload's send is retried, so a POST sent this way
430
+ must be safe to repeat; so must a PATCH, retried after a timeout.
431
+ - It spends no `max_requests` budget, unless called inside a query that
432
+ set one.
433
+ - It logs `VERB path` with `data`, so `data` should hold neither secrets
434
+ nor bulk bytes.
435
+
436
+ It sends JSON only: a file goes through [`upload_file`](#uploading-files).
437
+
438
+
388
439
  ## Errors, retries and logging
389
440
 
390
- - A failed request, or a response that is not valid JSON, raises
441
+ - A failed request, or a response typed JSON that does not parse, raises
391
442
  `Sequel::DatabaseError`, with Notion's error code and message in the
392
443
  text. A 404 raises its subclass `Sequel::Notion::NotFoundError`
393
444
  (`where(id:)` turns it into no row).
394
445
  - Responses 429, 502, 503 and 504 are retried up to four times, honouring
395
- `Retry-After`. Page creation is retried only after a 429, so a timeout
396
- cannot create a page twice.
446
+ `Retry-After`. So are timeouts and failed connections (refused, reset,
447
+ unresolved). Page creation and a file upload's send are retried only
448
+ after a 429 or when the connection never opened, so a timeout or a
449
+ reset cannot create a page twice, nor send a file Notion already holds
450
+ (it refuses a second send, checked live).
397
451
  - Requests go to the database's loggers (`DB.loggers << Logger.new($stdout)`)
398
452
  as `POST data_sources/…/query` with their body.
399
453
 
@@ -443,7 +497,11 @@ believes Notion accepts. These were also checked against
443
497
  - **Rollups:** a `show_original` rollup through 30 relations read in
444
498
  full, all 30 values, in a query and by id.
445
499
  - **Files:** a Notion-hosted file read from a page and written back
446
- unchanged, signed URL included, kept by Notion.
500
+ unchanged, signed URL included, kept by Notion. `upload_file` with
501
+ binary bytes and a non-ASCII name, attached through `File.uploaded`
502
+ with and without `name:`, read back and downloaded byte for byte; a
503
+ second send to the same upload refused; an unsent upload's
504
+ `expiry_time` an hour after its creation (2026-10-22).
447
505
  - **Pages:** lookups by id with or without dashes; creation with the
448
506
  `data_source_id` parent; writing, reading back and clearing every
449
507
  writable type; trashing and restoring; a relation of 26 pages read in
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "net/http"
4
5
 
5
6
  require "faraday"
6
7
  require "faraday/retry"
@@ -8,6 +9,7 @@ require "sequel"
8
9
 
9
10
  require "sequel/notion/dataset"
10
11
  require "sequel/notion/errors"
12
+ require "sequel/notion/file_uploads"
11
13
  require "sequel/notion/model_support"
12
14
  require "sequel/notion/page_api"
13
15
  require "sequel/notion/registry"
@@ -26,15 +28,40 @@ module Sequel
26
28
  # faraday-retry honours.
27
29
  RETRY_STATUSES = [429, 502, 503, 504].freeze
28
30
 
29
- # Creating a page is the one call a retry could duplicate: retry it
30
- # only when Notion refused it outright (rate limited).
31
- RETRY_IF = lambda do |env, _exception|
32
- !(env.method == :post && env.url.path.end_with?("/pages")) ||
33
- env.status == 429
31
+ # Exceptions worth another attempt: faraday-retry's timeouts, plus
32
+ # a failed connection (refused, reset, unresolved), which is not
33
+ # among them.
34
+ RETRY_EXCEPTIONS = [*Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS,
35
+ Faraday::ConnectionFailed].freeze
36
+
37
+ # Causes of a ConnectionFailed raised before the request left: no
38
+ # connection was ever opened, so nothing reached Notion.
39
+ UNSENT_ERRORS = [Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
40
+ Errno::ENETUNREACH, SocketError,
41
+ Net::OpenTimeout].freeze
42
+
43
+ # The POSTs a repeat could duplicate or break: creating a page, and
44
+ # sending a file, which Notion refuses once the upload holds one.
45
+ UNREPEATABLE = %r{/(?:pages|file_uploads/[^/]+/send)\z}
46
+
47
+ # Retry an unrepeatable POST only when Notion refused it outright
48
+ # (rate limited) or when it was never sent.
49
+ RETRY_IF = lambda do |env, exception|
50
+ !(env.method == :post && env.url.path.match?(UNREPEATABLE)) ||
51
+ env.status == 429 || UNSENT.call(exception)
52
+ end
53
+
54
+ # Did the exception prove nothing was sent? An adapter may raise a
55
+ # bare timeout, which wraps nothing.
56
+ UNSENT = lambda do |exception|
57
+ cause = exception.is_a?(Faraday::Error) &&
58
+ exception.wrapped_exception
59
+ UNSENT_ERRORS.any? { cause.is_a?(it) }
34
60
  end
35
61
 
36
62
  class Database < Sequel::Database
37
63
  include PageApi
64
+ include FileUploads
38
65
  include Registry
39
66
  include RequestBudget
40
67
  include SchemaLookup
@@ -88,16 +115,9 @@ module Sequel
88
115
  # HTTP failure re-raised as a Sequel::DatabaseError. Only its
89
116
  # 200 spends the query's budget, after the retries.
90
117
  def request(verb, path, body = nil)
91
- within_budget do
92
- synchronize do |conn|
93
- log_connection_yield("#{verb.upcase} #{path}", conn,
94
- body && [body]) do
95
- conn.public_send(verb, path, body).body
96
- end
97
- end
118
+ notion_call(verb, path, body && [body]) do |conn|
119
+ conn.public_send(verb, path, body).body
98
120
  end
99
- rescue Faraday::Error => e
100
- raise database_error(e)
101
121
  end
102
122
 
103
123
  # Where Sequel sends SQL to run: run, <<, truncate, the
@@ -108,6 +128,19 @@ module Sequel
108
128
 
109
129
  private
110
130
 
131
+ # Run the block with a pooled connection, under the budget,
132
+ # logged as "VERB path" with +logged+
133
+ def notion_call(verb, path, logged)
134
+ within_budget do
135
+ synchronize do |conn|
136
+ log_connection_yield("#{verb.upcase} #{path}", conn,
137
+ logged) { yield conn }
138
+ end
139
+ end
140
+ rescue Faraday::Error => e
141
+ raise database_error(e)
142
+ end
143
+
111
144
  # A number column is :float, and Sequel's model typecast would
112
145
  # turn 5 into 5.0; Notion keeps, and reads back, an Integer
113
146
  def typecast_value_float(value)
@@ -138,6 +171,7 @@ module Sequel
138
171
  interval: 0.5,
139
172
  backoff_factor: 2,
140
173
  methods: %i[get patch delete],
174
+ exceptions: RETRY_EXCEPTIONS,
141
175
  retry_if: RETRY_IF,
142
176
  retry_statuses: RETRY_STATUSES }
143
177
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Sequel
6
+ module Notion
7
+ # Notion's single-part file upload: create the upload, send its
8
+ # bytes as multipart/form-data, and hand back the id that a files
9
+ # property (File.uploaded) or a file block attaches.
10
+ module FileUploads
11
+ # Upload +data+, the file's bytes (a String, never a path, or
12
+ # anything with #read), as one part, and return the file
13
+ # upload's id. Notion keeps an upload for an hour unless it is
14
+ # attached.
15
+ #
16
+ # id = DB.upload_file(File.binread("q.pdf"),
17
+ # filename: "q.pdf",
18
+ # content_type: "application/pdf")
19
+ # DB[:leases].where(id: page).update(Docs: File.uploaded(id))
20
+ #
21
+ def upload_file(data, filename:, content_type:)
22
+ bytes = data.respond_to?(:read) ? data.read : data.to_str
23
+ upload = request(:post, "file_uploads",
24
+ mode: "single_part", filename: filename,
25
+ content_type: content_type)
26
+ send_file_upload(upload.fetch("id"), bytes,
27
+ filename, content_type)
28
+ upload["id"]
29
+ end
30
+
31
+ private
32
+
33
+ # The bytes go out as a String body under their own content
34
+ # type, which the :json middleware leaves alone; the log names
35
+ # the file rather than inspect it.
36
+ def send_file_upload(id, bytes, filename, content_type)
37
+ path = "file_uploads/#{id}/send"
38
+ body, type = multipart_body(bytes, filename, content_type)
39
+ logged = "#{filename} (#{bytes.bytesize} bytes)"
40
+ notion_call(:post, path, [logged]) do |conn|
41
+ conn.post(path, body, "Content-Type" => type).body
42
+ end
43
+ end
44
+
45
+ # One "file" part, as a browser encodes a form: a quote, CR
46
+ # or LF in the file name is percent-encoded.
47
+ def multipart_body(bytes, filename, content_type)
48
+ boundary = "sequel-notion-#{SecureRandom.hex(16)}"
49
+ name = filename.gsub(/["\r\n]/) { format("%%%02X", it.ord) }
50
+ head = "--#{boundary}\r\n" \
51
+ "Content-Disposition: form-data; name=\"file\"; " \
52
+ "filename=\"#{name}\"\r\n" \
53
+ "Content-Type: #{content_type}\r\n\r\n"
54
+ body = head.b + bytes.b + "\r\n--#{boundary}--\r\n".b
55
+ [body, "multipart/form-data; boundary=#{boundary}"]
56
+ end
57
+ end
58
+ end
59
+ end
@@ -151,6 +151,18 @@ module Sequel
151
151
  new(url: url, type: :external, name: name)
152
152
  end
153
153
 
154
+ # A file uploaded with Database#upload_file, to write to a
155
+ # files property; it reads back as a Notion-hosted file
156
+ #
157
+ # File.uploaded(DB.upload_file(io, filename: "q.pdf", …))
158
+ #
159
+ def self.uploaded(id, name: nil)
160
+ raw = { "type" => "file_upload",
161
+ "file_upload" => { "id" => id } }
162
+ raw["name"] = name if name
163
+ new(url: nil, type: :file_upload, name: name, raw: raw)
164
+ end
165
+
154
166
  # Wrap multiple URLs into File instances
155
167
  #
156
168
  # File.from_urls("https://a.com/1.pdf", "https://b.com/2.pdf")
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Sequel
4
4
  module Notion
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sequel-notion
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stéphane D'Alu
@@ -77,6 +77,7 @@ files:
77
77
  - lib/sequel/notion/dataset_truncation.rb
78
78
  - lib/sequel/notion/discovery.rb
79
79
  - lib/sequel/notion/errors.rb
80
+ - lib/sequel/notion/file_uploads.rb
80
81
  - lib/sequel/notion/filter_comparison.rb
81
82
  - lib/sequel/notion/filter_compiler.rb
82
83
  - lib/sequel/notion/filter_constants.rb