airtable-orm 0.2.3 → 0.3.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 +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +24 -2
- data/lib/airtable/orm/attributes.rb +7 -7
- data/lib/airtable/orm/configuration.rb +5 -2
- data/lib/airtable/orm/schema/memo.rb +69 -0
- data/lib/airtable/orm/schema.rb +43 -5
- data/lib/airtable/orm/version.rb +1 -1
- data/lib/airtable/orm.rb +2 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: daa9deb3cda25ce52f78e6b63b887cf08df87e68751d7068c0843d72964d10e3
|
|
4
|
+
data.tar.gz: 3bfcca81437c8a530de3eb5e92f893d45945dace06c1c6b105c4f797491b87f8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '07087e501496c4029784c2f29cae6779214473d636ad8d38cb7f000ff4f1cfc1c8be1d9c973f15f5949afb00df9bbbbe7ffee76851c7f333c129414dfdb849ac'
|
|
7
|
+
data.tar.gz: ca66c7ef561dba5dbb48f9c6e8f57d5cc38f9a16965d4c9a88560038d8a548f5f79b8aaf1414213dc3a6c1b4b77e46854170114dd49e8e50a85affde7d746555
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-11-15
|
|
4
|
+
|
|
5
|
+
Schema caching rework. Minor (not patch) because `Schema.fetch` and `Model.clear_schema_cache`
|
|
6
|
+
change observable behaviour and new public configuration is added — see Changed below.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- Schema changes now reach running processes without a restart (other processes need a shared
|
|
11
|
+
`config.cache`, e.g. `Rails.cache`). The per-model memo used to live
|
|
12
|
+
for the whole process, so neither `Schema.clear_cache` nor store expiry reached running puma or
|
|
13
|
+
worker processes: a new single-select option stayed invisible (and failed
|
|
14
|
+
`inclusion: field_options(...)` validations) until a restart.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- The base schema is memoized **per base** in each process (`Schema::Memo`, one per `base_id`)
|
|
19
|
+
instead of per model class: `Schema.fetch(base_id)` reads the memo, then `config.cache`, then
|
|
20
|
+
`/v0/meta`. The memo is trusted for `config.schema_memo_ttl`; `/v0/meta` is still called only
|
|
21
|
+
on a store miss.
|
|
22
|
+
- If a reload fails with `ApiError` or `ConnectionError`, `Schema.fetch` keeps serving the previous
|
|
23
|
+
copy, logs a warning to `config.logger` and retries after 30 seconds (or the TTL, if shorter).
|
|
24
|
+
Without a copy the error
|
|
25
|
+
is raised. `fetch(force_refresh: true)` raises on failure, leaving the copy as the fallback.
|
|
26
|
+
- `Schema.clear_cache(base_id)` also expires this process's memo, so the next read reloads (the
|
|
27
|
+
copy stays as the outage fallback); with a shared store, every other process follows within the
|
|
28
|
+
TTL.
|
|
29
|
+
- `Model.clear_schema_cache` now drops the memo of the model's whole base (shared by every model
|
|
30
|
+
on it), fallback copy included. `Airtable::ORM.configure` and `Airtable::ORM.reset!` drop every
|
|
31
|
+
memo.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- `config.schema_memo_ttl` (default `5.minutes`; `nil`/`0` disables the memo).
|
|
36
|
+
- `config.schema_cache_expiry` (default `24.hours`, the previous hard-coded value) sets how long
|
|
37
|
+
the schema lives in `config.cache`. `Schema::CACHE_EXPIRY` is deprecated and no longer read.
|
|
38
|
+
- `Schema.clear_memo(base_id)` and `Schema.reset_memos!` drop in-process memos.
|
|
39
|
+
- Explicit `concurrent-ruby ~> 1.1` dependency (ActiveSupport already pulls in a compatible
|
|
40
|
+
version).
|
|
41
|
+
|
|
3
42
|
## [0.2.3] - 2026-09-24
|
|
4
43
|
|
|
5
44
|
### Fixed
|
data/README.md
CHANGED
|
@@ -53,7 +53,9 @@ end
|
|
|
53
53
|
| `rate_limit` | `5` | Requests/second per base (Airtable throttles for 30 s above 5) |
|
|
54
54
|
| `logger` | null logger | Batch-update failure reporting |
|
|
55
55
|
| `http_logger` | `nil` | Set to a `Logger` to log Faraday requests |
|
|
56
|
-
| `cache` | in-process `MemoryCache` | Schema cache; anything responding to `fetch`/`delete` |
|
|
56
|
+
| `cache` | in-process `MemoryCache` | Schema cache; anything responding to `fetch(key, expires_in:)`/`delete` |
|
|
57
|
+
| `schema_cache_expiry` | `24.hours` | How long the base schema lives in `cache` before `/v0/meta` is re-read |
|
|
58
|
+
| `schema_memo_ttl` | `5.minutes` | How long each process trusts its in-memory schema before re-reading `cache`; `nil`/`0` disables the memo |
|
|
57
59
|
|
|
58
60
|
`Airtable::ORM.configured?` returns whether an `api_key` is set — handy for gating enqueue hooks so test/CI environments never touch the network.
|
|
59
61
|
|
|
@@ -75,10 +77,30 @@ end
|
|
|
75
77
|
- **Querying:** `all`, `where(formula:, sort:, max_records:)`, `first`, `find`, `find_many`, `find_by`/`find_by!`, `count`. Symbol conditions build (and escape) Airtable formulas; pagination is automatic.
|
|
76
78
|
- **Persistence:** `save`/`save!`, `create`/`create!`, `update`/`update!`, `destroy`, `reload`, plus `Model.update_many(records)` batching 10 per PATCH with a `BatchResult` (`updated`/`skipped`/`failed`).
|
|
77
79
|
- **Associations:** `has_many`/`belongs_to` readers, writers, `add_*`/`remove_*`, memoization and `Collection#preload` for eager loading.
|
|
78
|
-
- **Schema:** table/field metadata from `/v0/meta/bases`, cached via `config.cache`; `Model.field_options(:state)` reads select options.
|
|
80
|
+
- **Schema:** table/field metadata from `/v0/meta/bases`, cached via `config.cache`; `Model.field_options(:state)` reads select options. See [Schema caching](#schema-caching).
|
|
79
81
|
- **`#url`** — deeplink to the record in the Airtable UI.
|
|
80
82
|
- **`normalizes`** — available on ActiveModel 8.0+ (the DSL doesn't exist in 7.1).
|
|
81
83
|
|
|
84
|
+
## Schema caching
|
|
85
|
+
|
|
86
|
+
The base schema (tables, fields, select options) is cached on two levels, both keyed by base ID:
|
|
87
|
+
|
|
88
|
+
1. **Store** — `config.cache` holds the indexed base schema for `config.schema_cache_expiry`;
|
|
89
|
+
`/v0/meta` is only called on a miss.
|
|
90
|
+
2. **Per-process memo** — one memo per base, shared by every model on it, trusted for
|
|
91
|
+
`config.schema_memo_ttl` and then re-read through the store. If the re-read fails with an API
|
|
92
|
+
or connection error, the previous copy keeps serving (logged as a warning) and the re-read is
|
|
93
|
+
retried after 30 seconds (or the TTL, if shorter). With the memo disabled (`nil`/`0`), every
|
|
94
|
+
schema lookup reads the store.
|
|
95
|
+
|
|
96
|
+
After changing the schema in Airtable (e.g. a new single-select option), call
|
|
97
|
+
`Airtable::ORM::Schema.clear_cache(base_id)` — or `Schema.fetch(base_id, force_refresh: true)` to
|
|
98
|
+
refetch right away (it raises if Airtable is unreachable). The calling process sees the new schema
|
|
99
|
+
on its next read (barring a reload already in flight); with a **shared** store (e.g. `Rails.cache`) every other process sees it within
|
|
100
|
+
`schema_memo_ttl`, no restart needed. With the default in-process `MemoryCache`, other processes
|
|
101
|
+
refresh only when their own entry expires. `Model.clear_schema_cache` (or
|
|
102
|
+
`Schema.clear_memo(base_id)`) drops this process's memo for the base, without a fallback copy.
|
|
103
|
+
|
|
82
104
|
## Errors
|
|
83
105
|
|
|
84
106
|
Consumers rescue `Airtable::ORM::*` only — the HTTP client (Faraday today) never leaks, so swapping transports is not a breaking change.
|
|
@@ -42,22 +42,21 @@ module Airtable
|
|
|
42
42
|
ORM.config.table_id(table_name)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
#
|
|
46
|
-
# Memoized at class level to avoid repeated cache lookups
|
|
45
|
+
# Schema of this table; caching (memo, store, fallback) lives in Schema, keyed by base_id.
|
|
47
46
|
def schema
|
|
48
47
|
return {} unless table_name
|
|
49
48
|
|
|
50
|
-
|
|
49
|
+
Airtable::ORM::Schema.fetch(base_id)[table_id] || raise(
|
|
51
50
|
Airtable::ORM::ConfigurationError,
|
|
52
51
|
"No table #{table_id.inspect} (#{table_name.inspect}) in the fetched schema for base " \
|
|
53
52
|
"#{base_id.inspect} — check config.tables against the Airtable base"
|
|
54
53
|
)
|
|
55
54
|
end
|
|
56
55
|
|
|
57
|
-
#
|
|
58
|
-
#
|
|
56
|
+
# Drops this process's schema memo for the model's base (shared by every model on it,
|
|
57
|
+
# fallback copy included) and the field mapping.
|
|
59
58
|
def clear_schema_cache
|
|
60
|
-
|
|
59
|
+
Airtable::ORM::Schema.clear_memo(base_id)
|
|
61
60
|
@field_mapping = nil
|
|
62
61
|
end
|
|
63
62
|
|
|
@@ -83,8 +82,9 @@ module Airtable
|
|
|
83
82
|
def schema_options
|
|
84
83
|
return {} unless field_mapping.present?
|
|
85
84
|
|
|
85
|
+
table_schema = schema
|
|
86
86
|
field_mapping.each_with_object({}) do |(symbol, field_id), options|
|
|
87
|
-
field_info =
|
|
87
|
+
field_info = table_schema.dig(:fields, field_id)
|
|
88
88
|
next unless field_info
|
|
89
89
|
|
|
90
90
|
case field_info[:type]
|
|
@@ -10,7 +10,8 @@ module Airtable
|
|
|
10
10
|
class Configuration
|
|
11
11
|
attr_accessor :api_key, :base_id, :tables, :api_url,
|
|
12
12
|
:open_timeout, :read_timeout, :rate_limit,
|
|
13
|
-
:logger, :http_logger, :cache
|
|
13
|
+
:logger, :http_logger, :cache,
|
|
14
|
+
:schema_cache_expiry, :schema_memo_ttl
|
|
14
15
|
|
|
15
16
|
def initialize
|
|
16
17
|
@tables = {}
|
|
@@ -21,6 +22,8 @@ module Airtable
|
|
|
21
22
|
@logger = Logger.new(IO::NULL)
|
|
22
23
|
@http_logger = nil # set to a Logger to enable Faraday request logging
|
|
23
24
|
@cache = MemoryCache.new # schema cache default; hosts inject Rails.cache
|
|
25
|
+
@schema_cache_expiry = 24.hours # schema lifetime in `cache` before /v0/meta is re-read
|
|
26
|
+
@schema_memo_ttl = 5.minutes # per-process memo before re-reading `cache`; nil/0 = off
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
# Example: table_fields(:case) => { _id: "id", advisors: "fldgU5KU1qlN8eJ3v", ... }
|
|
@@ -41,7 +44,7 @@ module Airtable
|
|
|
41
44
|
# API on every Schema.fetch (and burn the 5 RPS budget on metadata). Hosts inject a real
|
|
42
45
|
# store (Rails.cache); this one honours only :expires_in. The mutex is held across the
|
|
43
46
|
# fetch block (all keys serialize behind it, and nesting fetch calls would deadlock) —
|
|
44
|
-
# fine
|
|
47
|
+
# fine, since nothing fetches from inside the schema fetch block.
|
|
45
48
|
class MemoryCache
|
|
46
49
|
def initialize
|
|
47
50
|
@mutex = Mutex.new
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airtable
|
|
4
|
+
module ORM
|
|
5
|
+
class Schema
|
|
6
|
+
# Per-process memo of ONE base's indexed schema in front of the cache store (Schema keeps
|
|
7
|
+
# one per base_id), trusted for config.schema_memo_ttl. If a reload fails with an API or
|
|
8
|
+
# connection error, the previous copy keeps serving and the reload is retried after
|
|
9
|
+
# FALLBACK_RETRY. No locking: concurrent reloads just re-read the store, and the last write
|
|
10
|
+
# wins (entries are frozen, an ivar write is atomic).
|
|
11
|
+
class Memo
|
|
12
|
+
Entry = Struct.new(:schema, :expires_at)
|
|
13
|
+
FALLBACK_RETRY = 30 # seconds (capped by the TTL)
|
|
14
|
+
|
|
15
|
+
def self.clock
|
|
16
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(base_id)
|
|
20
|
+
@base_id = base_id
|
|
21
|
+
@entry = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Memoized schema, reloading via the block once the TTL has elapsed.
|
|
25
|
+
def fetch
|
|
26
|
+
return yield unless memoizing?
|
|
27
|
+
|
|
28
|
+
entry = @entry
|
|
29
|
+
return entry.schema if entry && self.class.clock < entry.expires_at
|
|
30
|
+
|
|
31
|
+
store(yield, ttl)
|
|
32
|
+
rescue ApiError, ConnectionError => e
|
|
33
|
+
raise unless entry&.schema
|
|
34
|
+
|
|
35
|
+
ORM.config.logger.warn("Airtable schema refresh for base #{@base_id} failed, " \
|
|
36
|
+
"serving the previous copy: #{e.class}: #{e.message}")
|
|
37
|
+
store(entry.schema, [FALLBACK_RETRY, ttl].min)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Reload via the block now; raises on failure, leaving the previous copy as the fallback.
|
|
41
|
+
def refresh
|
|
42
|
+
memoizing? ? store(yield, ttl) : yield
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Force a reload on next read but keep the copy as the outage fallback.
|
|
46
|
+
def expire!
|
|
47
|
+
entry = @entry
|
|
48
|
+
@entry = Entry.new(entry.schema, -Float::INFINITY).freeze if entry
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def memoizing?
|
|
54
|
+
ttl.positive?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def ttl
|
|
58
|
+
ORM.config.schema_memo_ttl.to_f # nil → 0.0 (off)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Stamped after the load, so the TTL starts once the value is in hand.
|
|
62
|
+
def store(schema, expires_in)
|
|
63
|
+
@entry = Entry.new(schema, self.class.clock + expires_in).freeze
|
|
64
|
+
schema
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/airtable/orm/schema.rb
CHANGED
|
@@ -1,24 +1,61 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "concurrent/map"
|
|
4
|
+
|
|
3
5
|
module Airtable
|
|
4
6
|
module ORM
|
|
5
7
|
class Schema
|
|
6
8
|
CACHE_KEY = "airtable:schema"
|
|
9
|
+
# Superseded by config.schema_cache_expiry; no longer read.
|
|
7
10
|
CACHE_EXPIRY = 24.hours
|
|
11
|
+
deprecate_constant :CACHE_EXPIRY
|
|
12
|
+
|
|
13
|
+
MEMOS = Concurrent::Map.new # base_id → Memo
|
|
14
|
+
private_constant :MEMOS
|
|
8
15
|
|
|
9
16
|
class << self
|
|
17
|
+
# Indexed base schema: the per-process memo (config.schema_memo_ttl), then the cache store
|
|
18
|
+
# (config.schema_cache_expiry), then /v0/meta. force_refresh deletes the store entry,
|
|
19
|
+
# expires the memo and refetches; it raises on failure, leaving the memo's copy as the
|
|
20
|
+
# outage fallback.
|
|
10
21
|
def fetch(base_id, force_refresh: false)
|
|
11
|
-
|
|
22
|
+
base_id = base_id.to_s
|
|
23
|
+
memo = memo_for(base_id)
|
|
24
|
+
return memo.fetch { read_store(base_id) } unless force_refresh
|
|
12
25
|
|
|
13
|
-
|
|
26
|
+
clear_cache(base_id)
|
|
27
|
+
memo.refresh { read_store(base_id) }
|
|
14
28
|
end
|
|
15
29
|
|
|
30
|
+
# Deletes the store entry and expires this process's memo (it keeps its copy as the outage
|
|
31
|
+
# fallback); with a shared store, other processes follow once their schema_memo_ttl elapses.
|
|
16
32
|
def clear_cache(base_id)
|
|
17
|
-
cache.delete(cache_key(base_id))
|
|
33
|
+
cache.delete(cache_key(base_id)).tap { MEMOS[base_id.to_s]&.expire! }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Drops this process's memo for the base, fallback copy included.
|
|
37
|
+
def clear_memo(base_id)
|
|
38
|
+
MEMOS.delete(base_id.to_s)
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Drops every memo in this process (ORM.configure and ORM.reset!).
|
|
43
|
+
def reset_memos!
|
|
44
|
+
MEMOS.clear
|
|
18
45
|
end
|
|
19
46
|
|
|
20
47
|
private
|
|
21
48
|
|
|
49
|
+
def memo_for(base_id)
|
|
50
|
+
MEMOS.compute_if_absent(base_id) { Memo.new(base_id) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def read_store(base_id)
|
|
54
|
+
cache.fetch(cache_key(base_id), expires_in: ORM.config.schema_cache_expiry) do
|
|
55
|
+
fetch_from_api(base_id)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
22
59
|
def fetch_from_api(base_id)
|
|
23
60
|
response = client.connection.get("/v0/meta/bases/#{base_id}/tables")
|
|
24
61
|
parsed_response = response.body
|
|
@@ -31,8 +68,9 @@ module Airtable
|
|
|
31
68
|
end
|
|
32
69
|
|
|
33
70
|
# Index the FULL base schema — no filtering by the tables configured at fetch time.
|
|
34
|
-
# The cached payload outlives the config (
|
|
35
|
-
# fetch-time filter would hide tables added to config after the cache
|
|
71
|
+
# The cached payload outlives the config (config.schema_cache_expiry, a shared store in
|
|
72
|
+
# Rails hosts), so a fetch-time filter would hide tables added to config after the cache
|
|
73
|
+
# warmed up.
|
|
36
74
|
# Reads are keyed lookups (fetch(base_id)[table_id]), so no read-time filter is needed.
|
|
37
75
|
def indexed_schema(parsed_response)
|
|
38
76
|
tables = parsed_response.deep_symbolize_keys[:tables]
|
data/lib/airtable/orm/version.rb
CHANGED
data/lib/airtable/orm.rb
CHANGED
|
@@ -33,6 +33,7 @@ module Airtable
|
|
|
33
33
|
# The client memoizes api_key/timeouts/rate_limit at first request — without this
|
|
34
34
|
# reset a later configure (e.g. rotating the API key) would be silently ignored.
|
|
35
35
|
Http::Client.reset!
|
|
36
|
+
Schema.reset_memos! # a new cache store or tables must not be served from old memos
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
# True when an API key is configured — hosts gate network-touching hooks on this.
|
|
@@ -43,6 +44,7 @@ module Airtable
|
|
|
43
44
|
def reset!
|
|
44
45
|
@config = nil
|
|
45
46
|
Http::Client.reset!
|
|
47
|
+
Schema.reset_memos!
|
|
46
48
|
end
|
|
47
49
|
end
|
|
48
50
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: airtable-orm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Roman Sklenar
|
|
@@ -29,6 +29,20 @@ dependencies:
|
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '9'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: concurrent-ruby
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '1.1'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '1.1'
|
|
32
46
|
- !ruby/object:Gem::Dependency
|
|
33
47
|
name: faraday
|
|
34
48
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -105,6 +119,7 @@ files:
|
|
|
105
119
|
- lib/airtable/orm/persistence.rb
|
|
106
120
|
- lib/airtable/orm/querying.rb
|
|
107
121
|
- lib/airtable/orm/schema.rb
|
|
122
|
+
- lib/airtable/orm/schema/memo.rb
|
|
108
123
|
- lib/airtable/orm/testing.rb
|
|
109
124
|
- lib/airtable/orm/testing/stub_helpers.rb
|
|
110
125
|
- lib/airtable/orm/version.rb
|