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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 628a8ae1f053bb9b1f598608785b9a09062797d152e234d7cd2e2b1a4fefb982
4
- data.tar.gz: 30813eda1cfb49e6e74418be674490526697264c3d67b6d6f6edd0d3f194c810
3
+ metadata.gz: daa9deb3cda25ce52f78e6b63b887cf08df87e68751d7068c0843d72964d10e3
4
+ data.tar.gz: 3bfcca81437c8a530de3eb5e92f893d45945dace06c1c6b105c4f797491b87f8
5
5
  SHA512:
6
- metadata.gz: ba0c3abab62e8f8931c6f7ca3f84fe35b343e5341df5ea9652f641c1b8b6d146241be1cd3b554d84232b2d44a1651c475aee2f5abb8580a4b75b2faf9c971806
7
- data.tar.gz: 3394db605f8b6910d0e41a40c9087ba74daa36b3b5b60d5db65487fa870d4dbc4e0c56c4aed43d5476473e327fc7cf6bd9b6856078578a6809d78b9bc6a5fc49
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
- # Get schema for this table
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
- @schema ||= Airtable::ORM::Schema.fetch(base_id)[table_id] || raise(
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
- # Clear the memoized schema cache
58
- # Useful for testing or when the schema changes
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
- @schema = nil
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 = schema.dig(:fields, field_id)
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 for the rare schema refresh this exists for.
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
@@ -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
- clear_cache(base_id) if force_refresh
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
- cache.fetch(cache_key(base_id), expires_in: CACHE_EXPIRY) { fetch_from_api(base_id) }
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 (24h, shared store in Rails hosts), so a
35
- # fetch-time filter would hide tables added to config after the cache warmed up.
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]
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Airtable
4
4
  module ORM
5
- VERSION = "0.2.3"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
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.2.3
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