equipoise 0.1.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.
@@ -0,0 +1,136 @@
1
+ # =============================================================================
2
+ # Equipoise::Configuration
3
+ # =============================================================================
4
+ #
5
+ # Holds the producer's connection settings. api_key, base_url, and source are
6
+ # read from the environment LAZILY (on every read) unless explicitly assigned:
7
+ #
8
+ # EQUIPOISE_API_KEY → api_key (eq_live_… / eq_test_…)
9
+ # EQUIPOISE_API_URL → base_url (defaults to the production host)
10
+ # EQUIPOISE_SOURCE → source (the producer's source identifier)
11
+ #
12
+ # Reading from ENV at call time is what makes the key rotation-aware: rotate the
13
+ # secret in the environment and the next request picks it up — no redeploy. An
14
+ # explicit `config.api_key = "…"` captures the value and is NOT rotation-aware.
15
+ #
16
+ # Transport security: `validate!` refuses to send credentials over plaintext
17
+ # HTTP to a non-loopback host (an `http://` base_url would leak the Bearer key on
18
+ # the wire). Local dev (`http://localhost`) is allowed; set `allow_insecure_http`
19
+ # to permit a trusted TLS-terminating internal endpoint.
20
+ #
21
+ # Used by: Equipoise.configure, Equipoise::Client
22
+ # =============================================================================
23
+
24
+ module Equipoise
25
+ class Configuration
26
+ DEFAULT_BASE_URL = "https://app.equipoi.se/api/v1"
27
+ DEFAULT_OPEN_TIMEOUT = 5
28
+ DEFAULT_READ_TIMEOUT = 15
29
+ DEFAULT_MAX_RETRIES = 2
30
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
31
+ PRODUCTION_HOST = URI.parse(DEFAULT_BASE_URL).host # "app.equipoi.se"
32
+
33
+ attr_accessor :open_timeout,
34
+ :read_timeout,
35
+ :max_retries,
36
+ :retry_backoff_base,
37
+ :max_retry_backoff,
38
+ :allow_insecure_http,
39
+ :enabled,
40
+ :logger,
41
+ :user_agent
42
+ attr_writer :api_key,
43
+ :base_url,
44
+ :source
45
+
46
+ def initialize(env: ENV)
47
+ @env = env
48
+ @api_key = nil
49
+ @base_url = nil
50
+ @source = nil
51
+ @open_timeout = DEFAULT_OPEN_TIMEOUT
52
+ @read_timeout = DEFAULT_READ_TIMEOUT
53
+ @max_retries = DEFAULT_MAX_RETRIES
54
+ @retry_backoff_base = 0.5
55
+ @max_retry_backoff = 30
56
+ @allow_insecure_http = false
57
+ @enabled = true
58
+ @logger = nil
59
+ @user_agent = "equipoise-ruby/#{Equipoise::VERSION}"
60
+ end
61
+
62
+ # — Claude authored — test-mode kill switch (#5). When disabled the client
63
+ # short-circuits to a no-op before any network call OR validation, so a stray
64
+ # EQUIPOISE_API_KEY in a producer's test env can never make a live call.
65
+ def disabled?
66
+ !enabled
67
+ end
68
+
69
+ # — Claude authored — explicit assignment wins; otherwise read ENV each call
70
+ # (rotation-aware).
71
+ def api_key
72
+ resolve(@api_key, "EQUIPOISE_API_KEY")
73
+ end
74
+
75
+ def base_url
76
+ resolve(@base_url, "EQUIPOISE_API_URL") || DEFAULT_BASE_URL
77
+ end
78
+
79
+ def source
80
+ resolve(@source, "EQUIPOISE_SOURCE")
81
+ end
82
+
83
+ def validate!
84
+ raise ConfigurationError, "api_key is required (set EQUIPOISE_API_KEY or Equipoise.configure)" if api_key.to_s.empty?
85
+ raise ConfigurationError, "base_url is required" if base_url.to_s.empty?
86
+
87
+ ensure_secure_transport!
88
+ ensure_environment_match!
89
+ true
90
+ end
91
+
92
+ private
93
+
94
+ def resolve(explicit, env_key)
95
+ return explicit unless explicit.to_s.empty?
96
+
97
+ value = @env[env_key]
98
+ value.to_s.empty? ? nil : value
99
+ end
100
+
101
+ # — Claude authored — never transmit the Bearer key in cleartext to a remote
102
+ # host. https is always fine; http is allowed only for loopback dev or when
103
+ # the producer explicitly opts in for a trusted internal endpoint.
104
+ def ensure_secure_transport!
105
+ uri = URI.parse(base_url)
106
+ return if uri.scheme == "https"
107
+ return if allow_insecure_http
108
+ return if LOOPBACK_HOSTS.include?(uri.host)
109
+
110
+ raise ConfigurationError,
111
+ "Refusing to send API credentials over plaintext HTTP to #{uri.host.inspect}. " \
112
+ "Use https://, or set config.allow_insecure_http = true for a trusted internal endpoint."
113
+ end
114
+
115
+ # — Claude authored (#3) — the key prefix encodes its environment (eq_test_ /
116
+ # eq_live_); refuse the dangerous host mismatches no producer can see coming:
117
+ # a TEST key pointed at the production host (test creds writing the real CRM)
118
+ # or a LIVE key pointed at localhost (prod creds in a dev loop). Staging and
119
+ # custom-domain hosts are intentionally not flagged (no false positives).
120
+ def ensure_environment_match!
121
+ host = URI.parse(base_url).host
122
+ key_env = api_key.start_with?("eq_live_") ? :live : (api_key.start_with?("eq_test_") ? :test : nil)
123
+ return if key_env.nil? # non-standard key prefix — don't guess
124
+
125
+ if key_env == :test && host == PRODUCTION_HOST
126
+ raise ConfigurationError,
127
+ "A test key (eq_test_) is pointed at the production host (#{host}). This would write test " \
128
+ "credentials into the live CRM. Use a live key, or point base_url at your test/local host."
129
+ elsif key_env == :live && LOOPBACK_HOSTS.include?(host)
130
+ raise ConfigurationError,
131
+ "A live key (eq_live_) is pointed at a local host (#{host}). Use a test key (eq_test_) for " \
132
+ "local development, or point base_url at the production host."
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,149 @@
1
+ # =============================================================================
2
+ # Equipoise::Contacts
3
+ # =============================================================================
4
+ #
5
+ # The contact-sync resource — the producer's main surface. Maps 1:1 onto the
6
+ # receiver's sync endpoints:
7
+ #
8
+ # sync(external_id:, email:, name:, tags:, …) → POST /contacts/sync
9
+ # batch_sync([...]) → POST /contacts/sync/batch (≤500/call)
10
+ # deactivate(external_id:) → POST /contacts/sync/:id/deactivate
11
+ # find(external_id:) → GET /contacts/sync/:id
12
+ #
13
+ # `source` defaults to the client's configured source; an explicit arg overrides
14
+ # it per call. Idempotency: pass `idempotency_key:` to make a single sync's retry
15
+ # safe. Batch retries are safe via the receiver's `(source, external_id)` upsert
16
+ # (the receiver does not consume Idempotency-Key on the batch endpoint).
17
+ #
18
+ # Used by: Equipoise::Client#contacts
19
+ # =============================================================================
20
+
21
+ module Equipoise
22
+ class Contacts
23
+ BATCH_LIMIT = 500
24
+
25
+ def initialize(client)
26
+ @client = client
27
+ end
28
+
29
+ # — Claude authored — upsert one contact by (source, external_id). Unknown
30
+ # attributes (email/name/first_name/last_name/phone/tags/custom_fields/
31
+ # create_missing_fields) pass straight through; nils are dropped.
32
+ def sync(external_id:, source: nil, idempotency_key: nil, **attributes)
33
+ payload = { source: resolve_source(source), external_id: external_id.to_s }
34
+ .merge(attributes)
35
+ .compact
36
+
37
+ headers = idempotency_key ? { "Idempotency-Key" => idempotency_key } : {}
38
+ @client.request(:post, "/contacts/sync", body: payload, headers: headers)
39
+ end
40
+
41
+ # — Claude authored — upsert many contacts. Chunks to ≤500/call and returns a
42
+ # BatchResult aggregating every per-item result across chunks. It NEVER raises
43
+ # mid-batch: a per-item server rejection comes back as an `ok: false` item
44
+ # (the receiver returns 200 per chunk), and a chunk-level transport/HTTP
45
+ # failure marks that chunk's records failed so earlier chunks' successes are
46
+ # preserved and the producer can retry only the failures.
47
+ def batch_sync(records, source: nil, chunk_size: BATCH_LIMIT)
48
+ default_source = source || @client.config.source
49
+ prepared = records.map { |record| prepare_record(record, default_source) }
50
+ ensure_sources!(prepared)
51
+
52
+ items = []
53
+ prepared.each_slice(chunk_size) { |chunk| items.concat(sync_chunk(chunk)) }
54
+ BatchResult.new(items)
55
+ end
56
+
57
+ # — Claude authored — deactivate the external link; the Contact is retained.
58
+ def deactivate(external_id:, source: nil)
59
+ @client.request(:post, "#{sync_path(external_id)}/deactivate",
60
+ query: { source: resolve_source(source) })
61
+ end
62
+
63
+ # — Claude authored — read back the Contact linked to (source, external_id).
64
+ def find(external_id:, source: nil)
65
+ @client.request(:get, sync_path(external_id),
66
+ query: { source: resolve_source(source) })
67
+ end
68
+
69
+ # — Claude authored — record an external event on the contact's timeline.
70
+ # `event_type: "subscribed"`/`"unsubscribed"` toggles the contact's email
71
+ # subscription (state + activity); any other event_type logs a generic
72
+ # timeline event. `metadata` is a free-form hash of producer context.
73
+ def record_activity(external_id:, event_type:, source: nil, label: nil, occurred_at: nil, metadata: nil)
74
+ body = { event_type: event_type, label: label, occurred_at: occurred_at, metadata: metadata }.compact
75
+ @client.request(:post, "#{sync_path(external_id)}/activity",
76
+ query: { source: resolve_source(source) },
77
+ body: body)
78
+ end
79
+
80
+ # Convenience wrappers for the two subscription events.
81
+ def subscribe(external_id:, source: nil)
82
+ record_activity(external_id: external_id, event_type: "subscribed", source: source)
83
+ end
84
+
85
+ def unsubscribe(external_id:, source: nil)
86
+ record_activity(external_id: external_id, event_type: "unsubscribed", source: source)
87
+ end
88
+
89
+ private
90
+
91
+ def prepare_record(record, default_source)
92
+ record = Util.symbolize_keys(record)
93
+ record[:source] ||= default_source
94
+ record[:external_id] = record[:external_id].to_s if record[:external_id]
95
+ record.compact
96
+ end
97
+
98
+ # Fail fast locally (like single sync) when any record has no source, rather
99
+ # than shipping a source-less contact and getting an opaque server 422.
100
+ def ensure_sources!(prepared)
101
+ missing = prepared.select { |record| record[:source].to_s.empty? }
102
+ return if missing.empty?
103
+
104
+ ids = missing.map { |record| record[:external_id] }.compact
105
+ raise ArgumentError,
106
+ "source is required for every batch record (missing for: #{ids.join(', ')}); " \
107
+ "configure Equipoise.source or pass source:"
108
+ end
109
+
110
+ def sync_chunk(chunk)
111
+ response = @client.request(:post, "/contacts/sync/batch",
112
+ body: { contacts: chunk },
113
+ headers: { "Idempotency-Key" => chunk_idempotency_key(chunk) })
114
+ array_data(response.data)
115
+ rescue Equipoise::ApiError, Equipoise::ConnectionError => e
116
+ chunk.map { |record| failed_item(record, e) }
117
+ end
118
+
119
+ def array_data(data)
120
+ data.is_a?(Array) ? data : []
121
+ end
122
+
123
+ def failed_item(record, error)
124
+ status = error.respond_to?(:code) && error.code ? error.code : "error"
125
+ { "external_id" => record[:external_id], "status" => status, "ok" => false, "errors" => Array(error.message) }
126
+ end
127
+
128
+ def resolve_source(source)
129
+ resolved = source || @client.config.source
130
+ raise ArgumentError, "source is required (configure Equipoise.source or pass source:)" if resolved.to_s.empty?
131
+
132
+ resolved
133
+ end
134
+
135
+ # external_id may contain dots/spaces (e.g. "stripe.cus_42"); url-encode the
136
+ # path segment while leaving unreserved characters (incl. ".") intact.
137
+ def sync_path(external_id)
138
+ "/contacts/sync/#{ERB::Util.url_encode(external_id.to_s)}"
139
+ end
140
+
141
+ # Deterministic per-chunk key from the chunk contents: identical contents →
142
+ # identical key (a safe replay when/if the receiver honors it on batch),
143
+ # different contents → different key (no false replay). Forward-compatible;
144
+ # batch idempotency today rests on the (source, external_id) upsert.
145
+ def chunk_idempotency_key(chunk)
146
+ "batch-#{Digest::SHA256.hexdigest(JSON.generate(chunk))[0, 32]}"
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,61 @@
1
+ # =============================================================================
2
+ # Equipoise::CustomFields
3
+ # =============================================================================
4
+ #
5
+ # Manage a producer's typed custom-field definitions (the schema the producer
6
+ # owns). Values themselves ride along on a contact sync via
7
+ # `custom_fields: { key: value }`; this resource is for declaring/inspecting the
8
+ # field definitions up front.
9
+ #
10
+ # declare(key:, field_type:, options:, source:) → POST /custom_fields (upsert)
11
+ # list(customizable_type:) → GET /custom_fields
12
+ # get(key) → GET /custom_fields/:key
13
+ # update(key, label:, …) → PATCH /custom_fields/:key
14
+ # delete(key, purge_values:) → DELETE /custom_fields/:key
15
+ #
16
+ # Used by: Equipoise::Client#custom_fields
17
+ # =============================================================================
18
+
19
+ module Equipoise
20
+ class CustomFields
21
+ def initialize(client)
22
+ @client = client
23
+ end
24
+
25
+ def list(customizable_type: nil, page: nil, per_page: nil)
26
+ query = { customizable_type: customizable_type, page: page, per_page: per_page }.compact
27
+ @client.request(:get, "/custom_fields", query: query)
28
+ end
29
+
30
+ def get(key, customizable_type: nil)
31
+ query = { customizable_type: customizable_type }.compact
32
+ @client.request(:get, field_path(key), query: query)
33
+ end
34
+
35
+ # — Claude authored — create-or-update a field by key (server returns 201 vs
36
+ # 200). `customizable_type` from the body is ignored server-side (defaults to
37
+ # Contact); a client can't declare fields on arbitrary owner types.
38
+ def declare(key:, **attributes)
39
+ @client.request(:post, "/custom_fields", body: { custom_field: { key: key, **attributes }.compact })
40
+ end
41
+ alias upsert declare
42
+
43
+ def update(key, **attributes)
44
+ @client.request(:patch, field_path(key), body: { custom_field: attributes.compact })
45
+ end
46
+
47
+ # Returns true on a 204. Pass purge_values: true to delete a field that has
48
+ # values (otherwise the server replies 409).
49
+ def delete(key, purge_values: false)
50
+ query = purge_values ? { purge_values: true } : {}
51
+ @client.request(:delete, field_path(key), query: query)
52
+ true
53
+ end
54
+
55
+ private
56
+
57
+ def field_path(key)
58
+ "/custom_fields/#{ERB::Util.url_encode(key.to_s)}"
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,129 @@
1
+ # =============================================================================
2
+ # Equipoise errors
3
+ # =============================================================================
4
+ #
5
+ # Typed exception hierarchy so producers can rescue precisely:
6
+ #
7
+ # Equipoise::Error — base for everything the gem raises
8
+ # ├─ ConfigurationError — missing api_key/base_url (raised locally)
9
+ # ├─ ConnectionError — network failure after bounded retries
10
+ # └─ ApiError — the server returned a non-2xx
11
+ # ├─ AuthenticationError — 401 (missing/invalid/expired/revoked key)
12
+ # ├─ ForbiddenError — 403 (missing scope / forbidden_source)
13
+ # ├─ NotFoundError — 404
14
+ # ├─ ConflictError — 409 (sync race / locked custom field)
15
+ # ├─ ValidationError — 422 (#details carries the field errors)
16
+ # ├─ RateLimitError — 429 (#retry_after seconds)
17
+ # └─ ServerError — 5xx
18
+ #
19
+ # ApiError.from_response builds the right subclass from a parsed Response,
20
+ # tolerating both the `{error:{code,message,details}}` envelope and the legacy
21
+ # rack-attack `{error:"Rate limit exceeded", retry_after:…}` body.
22
+ #
23
+ # Used by: Equipoise::Client, every resource
24
+ # =============================================================================
25
+
26
+ module Equipoise
27
+ class Error < StandardError
28
+ # — Claude authored — the retry taxonomy lives in the gem so no producer has
29
+ # to memorize which failures are transient. Base default: not retryable.
30
+ # A producer's job becomes: `rescue Equipoise::Error => e; raise if e.retryable?`.
31
+ def retryable?
32
+ false
33
+ end
34
+ end
35
+
36
+ class ConfigurationError < Error; end
37
+
38
+ # A transport failure (timeout, refused, DNS, TLS). Always worth a retry.
39
+ class ConnectionError < Error
40
+ def retryable?
41
+ true
42
+ end
43
+ end
44
+
45
+ class ApiError < Error
46
+ attr_reader :status, :code, :details, :response
47
+
48
+ def initialize(message = nil, status: nil, code: nil, details: nil, response: nil)
49
+ super(message)
50
+ @status = status
51
+ @code = code
52
+ @details = details
53
+ @response = response
54
+ end
55
+
56
+ # 429 + any 5xx are transient (back off and retry); every 4xx is a permanent
57
+ # client error the producer must fix (validation, auth, scope, not-found).
58
+ def retryable?
59
+ status == 429 || status.to_i >= 500
60
+ end
61
+
62
+ # — Claude authored — pick the subclass by HTTP status and parse the error
63
+ # envelope. Robust to an empty body (401) and the legacy string error body.
64
+ def self.from_response(response)
65
+ status = response.status
66
+ body = response.body
67
+ payload = body.is_a?(Hash) ? body["error"] : nil
68
+
69
+ code, message, details =
70
+ if payload.is_a?(Hash)
71
+ [payload["code"], payload["message"], payload["details"]]
72
+ elsif payload.is_a?(String)
73
+ [nil, payload, nil]
74
+ else
75
+ [nil, nil, nil]
76
+ end
77
+
78
+ message ||= default_message(status)
79
+ klass = class_for(status)
80
+
81
+ if klass == RateLimitError
82
+ klass.new(message, status: status, code: code, details: details,
83
+ response: response, retry_after: response.retry_after)
84
+ else
85
+ klass.new(message, status: status, code: code, details: details, response: response)
86
+ end
87
+ end
88
+
89
+ def self.class_for(status)
90
+ case status
91
+ when 401 then AuthenticationError
92
+ when 403 then ForbiddenError
93
+ when 404 then NotFoundError
94
+ when 409 then ConflictError
95
+ when 422 then ValidationError
96
+ when 429 then RateLimitError
97
+ when 500..599 then ServerError
98
+ else ApiError
99
+ end
100
+ end
101
+
102
+ def self.default_message(status)
103
+ {
104
+ 401 => "Unauthorized",
105
+ 403 => "Forbidden",
106
+ 404 => "Not found",
107
+ 409 => "Conflict",
108
+ 422 => "Validation failed",
109
+ 429 => "Rate limit exceeded"
110
+ }.fetch(status) { status >= 500 ? "Server error" : "Request failed (#{status})" }
111
+ end
112
+ end
113
+
114
+ class AuthenticationError < ApiError; end
115
+ class ForbiddenError < ApiError; end
116
+ class NotFoundError < ApiError; end
117
+ class ConflictError < ApiError; end
118
+ class ValidationError < ApiError; end
119
+ class ServerError < ApiError; end
120
+
121
+ class RateLimitError < ApiError
122
+ attr_reader :retry_after
123
+
124
+ def initialize(message = nil, retry_after: nil, **opts)
125
+ super(message, **opts)
126
+ @retry_after = retry_after
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,17 @@
1
+ # =============================================================================
2
+ # Equipoise::Railtie (loaded only under Rails)
3
+ # =============================================================================
4
+ #
5
+ # When the gem is required inside a Rails app, make the optional Syncable concern
6
+ # available without a manual require. The generator (`rails g equipoise:install`)
7
+ # writes the initializer. Plain Ruby usage never touches this file.
8
+ #
9
+ # Used by: lib/equipoise.rb (required only when Rails::Railtie is defined)
10
+ # =============================================================================
11
+
12
+ require "equipoise/syncable"
13
+
14
+ module Equipoise
15
+ class Railtie < Rails::Railtie
16
+ end
17
+ end
@@ -0,0 +1,68 @@
1
+ # =============================================================================
2
+ # Equipoise::Response
3
+ # =============================================================================
4
+ #
5
+ # A thin wrapper over a successful API response: the HTTP status, the
6
+ # (downcased) header hash, and the parsed JSON body. The whole API uses a
7
+ # `{ data, meta }` envelope, so `#data` / `#meta` unwrap it, and `#sync_status`
8
+ # surfaces the `meta.sync_status` ("created" | "updated") the sync endpoints add.
9
+ #
10
+ # `#retry_after` is the single Retry-After parser for the whole gem (used by both
11
+ # the retry-loop backoff and the surfaced RateLimitError): it reads the standard
12
+ # header, falls back to the legacy rack-attack body `retry_after`, and supports
13
+ # BOTH delta-seconds and the RFC 7231 HTTP-date form.
14
+ #
15
+ # Used by: Equipoise::Client, Equipoise::Contacts, Equipoise::CustomFields, ApiError
16
+ # =============================================================================
17
+
18
+ module Equipoise
19
+ class Response
20
+ attr_reader :status, :headers, :body
21
+
22
+ def initialize(status:, headers:, body:)
23
+ @status = status
24
+ @headers = headers || {}
25
+ @body = body
26
+ end
27
+
28
+ def success?
29
+ (200..299).cover?(status)
30
+ end
31
+
32
+ # The envelope's `data` (Hash or Array). Falls back to the whole body when a
33
+ # response isn't enveloped (defensive — every documented 2xx is).
34
+ def data
35
+ body.is_a?(Hash) && body.key?("data") ? body["data"] : body
36
+ end
37
+
38
+ def meta
39
+ body.is_a?(Hash) ? (body["meta"] || {}) : {}
40
+ end
41
+
42
+ def sync_status
43
+ meta["sync_status"]
44
+ end
45
+
46
+ # Seconds to wait, from the Retry-After header or the legacy body field.
47
+ # Returns an Integer (or nil when neither is present / unparseable).
48
+ def retry_after
49
+ raw = headers["retry-after"]
50
+ raw = body["retry_after"] if raw.to_s.empty? && body.is_a?(Hash)
51
+ parse_retry_after(raw)
52
+ end
53
+
54
+ private
55
+
56
+ def parse_retry_after(raw)
57
+ value = raw.to_s.strip
58
+ return nil if value.empty?
59
+ return value.to_i if value.match?(/\A\d+\z/) # delta-seconds
60
+
61
+ # RFC 7231 also allows an HTTP-date; convert to seconds-from-now (≥ 0).
62
+ seconds = (Time.httpdate(value) - Time.now).ceil
63
+ seconds.negative? ? 0 : seconds
64
+ rescue ArgumentError
65
+ nil
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,78 @@
1
+ # =============================================================================
2
+ # Equipoise::Syncable (optional Rails add-on)
3
+ # =============================================================================
4
+ #
5
+ # Mix into a producer's model to keep an Equipoise Contact in lockstep with it.
6
+ # Dependency-free (no ActiveSupport required): when the host responds to
7
+ # `after_commit` (an ActiveRecord model), it wires the create/update hook;
8
+ # otherwise it's inert, so a PORO can include it for testing.
9
+ #
10
+ # class User < ApplicationRecord
11
+ # include Equipoise::Syncable
12
+ #
13
+ # def equipoise_external_id = id.to_s
14
+ # def equipoise_contact_attributes
15
+ # { email:, name: full_name, tags: ["AMP"] }
16
+ # end
17
+ #
18
+ # # call from your soft-delete path:
19
+ # def soft_delete_account!
20
+ # super
21
+ # deactivate_from_equipoise
22
+ # end
23
+ # end
24
+ #
25
+ # The default `equipoise_sync!` posts inline; in production wrap it in a job
26
+ # (override `equipoise_sync!` to enqueue) so a web request never blocks on the
27
+ # round-trip. In dev, ensure the queue runs inline or the post never fires.
28
+ #
29
+ # Producer-side firing is exercised in AMP (S13); the gem suite tests the
30
+ # transport methods directly.
31
+ #
32
+ # Used by: producer models that opt in (e.g. AMP's User)
33
+ # =============================================================================
34
+
35
+ module Equipoise
36
+ module Syncable
37
+ def self.included(base)
38
+ return unless base.respond_to?(:after_commit)
39
+
40
+ base.after_commit :equipoise_sync!, on: [:create, :update]
41
+ end
42
+
43
+ # Override to enqueue instead of posting inline. No-op until configured so a
44
+ # boot before credentials exist (or a test env) doesn't make live calls.
45
+ # Rescues Equipoise::Error so a transient API/transport failure in this
46
+ # after_commit hook never propagates out of the host's `save!` (Rails 7.1+
47
+ # re-raises after_commit exceptions). Best-effort, like envelope tracking.
48
+ # Host bugs (a NoMethodError in equipoise_contact_attributes) still surface.
49
+ def equipoise_sync!
50
+ return unless Equipoise.configured?
51
+
52
+ sync_to_equipoise
53
+ rescue Equipoise::Error => e
54
+ Equipoise.configuration.logger&.warn(
55
+ "[equipoise] sync failed for #{self.class}##{equipoise_external_id}: #{e.message}"
56
+ )
57
+ end
58
+
59
+ def sync_to_equipoise
60
+ Equipoise.client.contacts.sync(external_id: equipoise_external_id, **equipoise_contact_attributes)
61
+ end
62
+
63
+ def deactivate_from_equipoise
64
+ Equipoise.client.contacts.deactivate(external_id: equipoise_external_id)
65
+ end
66
+
67
+ # — Sensible default; override in the host model when the external id isn't
68
+ # the primary key.
69
+ def equipoise_external_id
70
+ id.to_s
71
+ end
72
+
73
+ # Host models MUST implement this — the attributes posted on each sync.
74
+ def equipoise_contact_attributes
75
+ raise NotImplementedError, "#{self.class} must define #equipoise_contact_attributes"
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,25 @@
1
+ # =============================================================================
2
+ # Equipoise::Util
3
+ # =============================================================================
4
+ #
5
+ # Small stateless helpers shared across the gem. Currently just the symbol-key
6
+ # transform used by both Contacts (normalizing caller records) and BatchResult
7
+ # (normalizing server per-item results) — kept in one place so the two can't
8
+ # drift.
9
+ #
10
+ # Used by: Equipoise::Contacts, Equipoise::BatchResult
11
+ # =============================================================================
12
+
13
+ module Equipoise
14
+ module Util
15
+ module_function
16
+
17
+ # — Claude authored — shallow string→symbol key transform; passes non-Hash
18
+ # input through untouched.
19
+ def symbolize_keys(hash)
20
+ return hash unless hash.is_a?(Hash)
21
+
22
+ hash.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,12 @@
1
+ # =============================================================================
2
+ # Equipoise::VERSION
3
+ # =============================================================================
4
+ #
5
+ # Single source of truth for the gem version, read by the gemspec and exposed
6
+ # at runtime. Pre-1.0 while the gem is proven against AMP before any public
7
+ # RubyGems publish.
8
+ # =============================================================================
9
+
10
+ module Equipoise
11
+ VERSION = "0.1.0"
12
+ end