attio-rails 0.1.2 → 0.2.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/.github/workflows/{release.yml → build-and-publish.yml} +3 -13
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/docs.yml +2 -2
- data/CHANGELOG.md +40 -1
- data/CONCEPTS.md +448 -0
- data/Gemfile +1 -1
- data/lib/attio/rails/rspec/helpers.rb +209 -0
- data/lib/attio/rails/rspec/matchers.rb +145 -0
- data/lib/attio/rails/rspec.rb +9 -0
- data/lib/attio/rails/version.rb +1 -1
- metadata +11 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5a8102663f11e79d1701d5535c84d290bbe469eecff5dc9dc184cd32d3cbb6f1
|
4
|
+
data.tar.gz: 9c44c1dfec3c6ac14507073dadafad96d2c8778f7243476324244757a13da768
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8b36523aa233614f1d9ce43ad20b18080c7459f6bda2e2da67114b926a2dfa10a60cf569e9866ca86a644032daf8c27b6050bd7e99e57366202b5ef8e74255e9
|
7
|
+
data.tar.gz: db16d544bcdd62cd6f9f7434c203d252b15eeafb2541a84d57b1196901f3c0ea6a3ac8682b2fc903a38cd8b978e6458208a3380b262374417bc252e2dbf7faf0
|
@@ -1,4 +1,4 @@
|
|
1
|
-
name:
|
1
|
+
name: Build and Publish
|
2
2
|
|
3
3
|
on:
|
4
4
|
push:
|
@@ -6,7 +6,7 @@ on:
|
|
6
6
|
- 'v*.*.*'
|
7
7
|
|
8
8
|
jobs:
|
9
|
-
|
9
|
+
build-and-publish:
|
10
10
|
runs-on: ubuntu-latest
|
11
11
|
|
12
12
|
permissions:
|
@@ -30,7 +30,7 @@ jobs:
|
|
30
30
|
mkdir -p ~/.gem
|
31
31
|
cat > ~/.gem/credentials << EOF
|
32
32
|
---
|
33
|
-
:rubygems_api_key: ${{ secrets.
|
33
|
+
:rubygems_api_key: ${{ secrets.RUBYGEMS_AUTH_TOKEN }}
|
34
34
|
EOF
|
35
35
|
chmod 0600 ~/.gem/credentials
|
36
36
|
|
@@ -58,13 +58,3 @@ jobs:
|
|
58
58
|
env:
|
59
59
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
60
60
|
|
61
|
-
- name: Update GitHub Pages with documentation
|
62
|
-
run: |
|
63
|
-
git config --local user.email "action@github.com"
|
64
|
-
git config --local user.name "GitHub Action"
|
65
|
-
git checkout --orphan gh-pages
|
66
|
-
git rm -rf .
|
67
|
-
cp -r docs/* .
|
68
|
-
git add -A
|
69
|
-
git commit -m "Update documentation for ${{ github.ref_name }}"
|
70
|
-
git push origin gh-pages --force
|
data/.github/workflows/ci.yml
CHANGED
@@ -48,7 +48,7 @@ jobs:
|
|
48
48
|
|
49
49
|
- name: Upload coverage to Codecov
|
50
50
|
if: matrix.ruby-version == '3.4' && matrix.rails-version == '8.0'
|
51
|
-
uses: codecov/codecov-action@
|
51
|
+
uses: codecov/codecov-action@v5
|
52
52
|
with:
|
53
53
|
files: ./coverage/coverage.xml,./coverage/.resultset.json
|
54
54
|
flags: unittests
|
data/.github/workflows/docs.yml
CHANGED
@@ -2,7 +2,7 @@ name: Documentation
|
|
2
2
|
|
3
3
|
on:
|
4
4
|
push:
|
5
|
-
branches: [
|
5
|
+
branches: [ master ]
|
6
6
|
workflow_dispatch:
|
7
7
|
|
8
8
|
permissions:
|
@@ -28,7 +28,7 @@ jobs:
|
|
28
28
|
bundler-cache: true
|
29
29
|
|
30
30
|
- name: Setup Pages
|
31
|
-
uses: actions/configure-pages@
|
31
|
+
uses: actions/configure-pages@v5
|
32
32
|
|
33
33
|
- name: Generate documentation
|
34
34
|
run: |
|
data/CHANGELOG.md
CHANGED
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
7
7
|
|
8
8
|
## [Unreleased]
|
9
9
|
|
10
|
+
## [0.2.0] - 2025-01-11
|
11
|
+
|
12
|
+
### Added
|
13
|
+
- **BatchSync** class for efficient bulk synchronization operations
|
14
|
+
- **ActiveJob integration** with dedicated `AttioSyncJob` for background processing
|
15
|
+
- **Callbacks support** - `before_attio_sync` and `after_attio_sync` hooks
|
16
|
+
- **Transform support** - Custom attribute transformation before syncing
|
17
|
+
- **Error handlers** - Configurable error handling with `:on_error` option
|
18
|
+
- **RSpec test helpers** - Comprehensive testing utilities for Attio operations
|
19
|
+
- **Concepts documentation** - Detailed architecture guide with Mermaid diagrams
|
20
|
+
- **Configuration enhancements**:
|
21
|
+
- `queue` option for ActiveJob queue configuration
|
22
|
+
- `raise_on_missing_record` option for missing record behavior
|
23
|
+
- **100% test coverage** with comprehensive test suite
|
24
|
+
|
25
|
+
### Changed
|
26
|
+
- Enhanced `Syncable` concern with callbacks and transforms
|
27
|
+
- Improved error handling with environment-specific behavior
|
28
|
+
- Renamed GitHub Actions workflow from `release.yml` to `build-and-publish.yml`
|
29
|
+
- Updated README with comprehensive examples and usage patterns
|
30
|
+
|
31
|
+
### Fixed
|
32
|
+
- RuboCop linting issues for better code quality
|
33
|
+
- Test coverage gaps - achieved 100% coverage
|
34
|
+
|
35
|
+
## [0.1.2] - 2025-01-11
|
36
|
+
|
37
|
+
### Changed
|
38
|
+
- Updated attio dependency to 0.1.3
|
39
|
+
- Applied Stripe's RuboCop configuration
|
40
|
+
- Achieved 100% test coverage
|
41
|
+
|
42
|
+
### Fixed
|
43
|
+
- All RuboCop offenses auto-corrected
|
44
|
+
- Test failures resolved
|
45
|
+
|
10
46
|
## [0.1.1] - 2025-01-11
|
11
47
|
|
12
48
|
### Added
|
@@ -44,5 +80,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
44
80
|
- Batch operations support
|
45
81
|
- Custom field transformations
|
46
82
|
|
47
|
-
[Unreleased]: https://github.com/idl3/attio-rails/compare/v0.
|
83
|
+
[Unreleased]: https://github.com/idl3/attio-rails/compare/v0.2.0...HEAD
|
84
|
+
[0.2.0]: https://github.com/idl3/attio-rails/compare/v0.1.2...v0.2.0
|
85
|
+
[0.1.2]: https://github.com/idl3/attio-rails/compare/v0.1.1...v0.1.2
|
86
|
+
[0.1.1]: https://github.com/idl3/attio-rails/compare/v0.1.0...v0.1.1
|
48
87
|
[0.1.0]: https://github.com/idl3/attio-rails/releases/tag/v0.1.0
|
data/CONCEPTS.md
ADDED
@@ -0,0 +1,448 @@
|
|
1
|
+
# Attio Rails - Concepts & Architecture
|
2
|
+
|
3
|
+
## Table of Contents
|
4
|
+
- [Core Concepts](#core-concepts)
|
5
|
+
- [Architecture Overview](#architecture-overview)
|
6
|
+
- [Sync Flow](#sync-flow)
|
7
|
+
- [Batch Processing](#batch-processing)
|
8
|
+
- [Error Handling & Retry Strategy](#error-handling--retry-strategy)
|
9
|
+
- [Testing Strategy](#testing-strategy)
|
10
|
+
|
11
|
+
## Core Concepts
|
12
|
+
|
13
|
+
### 1. ActiveRecord Integration Pattern
|
14
|
+
|
15
|
+
The gem uses the **Concern pattern** to mix functionality into ActiveRecord models. This provides a clean, Rails-idiomatic interface:
|
16
|
+
|
17
|
+
```mermaid
|
18
|
+
graph TD
|
19
|
+
A[ActiveRecord Model] -->|includes| B[Attio::Rails::Concerns::Syncable]
|
20
|
+
B -->|provides| C[Sync Methods]
|
21
|
+
B -->|provides| D[Callbacks]
|
22
|
+
B -->|provides| E[Attribute Mapping]
|
23
|
+
|
24
|
+
C --> F[sync_to_attio_now]
|
25
|
+
C --> G[sync_to_attio_later]
|
26
|
+
C --> H[remove_from_attio]
|
27
|
+
|
28
|
+
D --> I[before_attio_sync]
|
29
|
+
D --> J[after_attio_sync]
|
30
|
+
D --> K[ActiveRecord Callbacks]
|
31
|
+
```
|
32
|
+
|
33
|
+
### 2. Attribute Mapping System
|
34
|
+
|
35
|
+
The attribute mapping system supports multiple mapping strategies:
|
36
|
+
|
37
|
+
```mermaid
|
38
|
+
graph LR
|
39
|
+
A[Model Attributes] --> B{Mapping Type}
|
40
|
+
B -->|Symbol| C[Method Call]
|
41
|
+
B -->|String| D[Method Call]
|
42
|
+
B -->|Proc/Lambda| E[Dynamic Evaluation]
|
43
|
+
B -->|Static Value| F[Direct Value]
|
44
|
+
|
45
|
+
C --> G[Attio Attributes]
|
46
|
+
D --> G
|
47
|
+
E --> G
|
48
|
+
F --> G
|
49
|
+
```
|
50
|
+
|
51
|
+
**Example mappings:**
|
52
|
+
```ruby
|
53
|
+
{
|
54
|
+
email: :email_address, # Symbol -> calls model.email_address
|
55
|
+
name: "full_name", # String -> calls model.full_name
|
56
|
+
type: "customer", # Static -> always "customer"
|
57
|
+
count: ->(m) { m.items.count } # Lambda -> evaluated dynamically
|
58
|
+
}
|
59
|
+
```
|
60
|
+
|
61
|
+
## Architecture Overview
|
62
|
+
|
63
|
+
### System Components
|
64
|
+
|
65
|
+
```mermaid
|
66
|
+
graph TB
|
67
|
+
subgraph "Rails Application"
|
68
|
+
A[ActiveRecord Model]
|
69
|
+
B[ActiveJob Queue]
|
70
|
+
end
|
71
|
+
|
72
|
+
subgraph "Attio Rails Gem"
|
73
|
+
C[Syncable Concern]
|
74
|
+
D[AttioSyncJob]
|
75
|
+
E[BatchSync]
|
76
|
+
F[Configuration]
|
77
|
+
end
|
78
|
+
|
79
|
+
subgraph "External"
|
80
|
+
G[Attio API]
|
81
|
+
end
|
82
|
+
|
83
|
+
A -->|includes| C
|
84
|
+
C -->|enqueues| D
|
85
|
+
C -->|uses| E
|
86
|
+
D -->|processes| B
|
87
|
+
E -->|bulk operations| G
|
88
|
+
D -->|sync/delete| G
|
89
|
+
C -->|immediate sync| G
|
90
|
+
F -->|configures| C
|
91
|
+
F -->|configures| D
|
92
|
+
```
|
93
|
+
|
94
|
+
## Sync Flow
|
95
|
+
|
96
|
+
### Automatic Sync Lifecycle
|
97
|
+
|
98
|
+
```mermaid
|
99
|
+
sequenceDiagram
|
100
|
+
participant User
|
101
|
+
participant Model
|
102
|
+
participant Syncable
|
103
|
+
participant AttioSyncJob
|
104
|
+
participant AttioAPI
|
105
|
+
|
106
|
+
User->>Model: create/update/destroy
|
107
|
+
Model->>Syncable: after_commit callback
|
108
|
+
|
109
|
+
alt Sync Enabled & Conditions Met
|
110
|
+
alt Background Sync
|
111
|
+
Syncable->>AttioSyncJob: enqueue job
|
112
|
+
AttioSyncJob-->>AttioSyncJob: process async
|
113
|
+
AttioSyncJob->>AttioAPI: sync data
|
114
|
+
AttioAPI-->>AttioSyncJob: response
|
115
|
+
AttioSyncJob->>Model: update attio_record_id
|
116
|
+
else Immediate Sync
|
117
|
+
Syncable->>AttioAPI: sync data
|
118
|
+
AttioAPI-->>Syncable: response
|
119
|
+
Syncable->>Model: update attio_record_id
|
120
|
+
end
|
121
|
+
else Sync Disabled or Conditions Not Met
|
122
|
+
Syncable-->>Model: skip sync
|
123
|
+
end
|
124
|
+
```
|
125
|
+
|
126
|
+
### Manual Sync Options
|
127
|
+
|
128
|
+
```mermaid
|
129
|
+
graph TD
|
130
|
+
A[Manual Sync Trigger] --> B{Sync Method}
|
131
|
+
|
132
|
+
B -->|sync_to_attio_now| C[Immediate Sync]
|
133
|
+
B -->|sync_to_attio_later| D[Background Job]
|
134
|
+
B -->|sync_to_attio| E{Config Check}
|
135
|
+
|
136
|
+
E -->|background_sync=true| D
|
137
|
+
E -->|background_sync=false| C
|
138
|
+
|
139
|
+
C --> F[Direct API Call]
|
140
|
+
D --> G[Enqueue AttioSyncJob]
|
141
|
+
|
142
|
+
F --> H[Attio API]
|
143
|
+
G --> I[Job Queue]
|
144
|
+
I --> H
|
145
|
+
```
|
146
|
+
|
147
|
+
## Batch Processing
|
148
|
+
|
149
|
+
### BatchSync Flow
|
150
|
+
|
151
|
+
```mermaid
|
152
|
+
flowchart TD
|
153
|
+
A[BatchSync.perform] --> B[Initialize Results Hash]
|
154
|
+
B --> C[Process in Batches]
|
155
|
+
|
156
|
+
C --> D{Async Mode?}
|
157
|
+
|
158
|
+
D -->|Yes| E[Enqueue Batch]
|
159
|
+
D -->|No| F[Sync Batch]
|
160
|
+
|
161
|
+
E --> G[For Each Record]
|
162
|
+
G --> H[Enqueue AttioSyncJob]
|
163
|
+
|
164
|
+
F --> I[For Each Record]
|
165
|
+
I --> J{Has attio_record_id?}
|
166
|
+
|
167
|
+
J -->|Yes| K[Update Record]
|
168
|
+
J -->|No| L[Create Record]
|
169
|
+
|
170
|
+
K --> M[API Call]
|
171
|
+
L --> M
|
172
|
+
|
173
|
+
M --> N{Success?}
|
174
|
+
N -->|Yes| O[Add to success array]
|
175
|
+
N -->|No| P[Add to failed array]
|
176
|
+
|
177
|
+
H --> Q[Add to success array]
|
178
|
+
|
179
|
+
O --> R[Return Results]
|
180
|
+
P --> R
|
181
|
+
Q --> R
|
182
|
+
```
|
183
|
+
|
184
|
+
### Batch Processing Strategies
|
185
|
+
|
186
|
+
```mermaid
|
187
|
+
graph LR
|
188
|
+
A[Large Dataset] --> B[find_in_batches]
|
189
|
+
B --> C[Batch 1]
|
190
|
+
B --> D[Batch 2]
|
191
|
+
B --> E[Batch N]
|
192
|
+
|
193
|
+
C --> F{Processing Mode}
|
194
|
+
D --> F
|
195
|
+
E --> F
|
196
|
+
|
197
|
+
F -->|Async| G[Job Queue]
|
198
|
+
F -->|Sync| H[Direct Processing]
|
199
|
+
|
200
|
+
G --> I[Parallel Processing]
|
201
|
+
H --> J[Sequential Processing]
|
202
|
+
```
|
203
|
+
|
204
|
+
## Error Handling & Retry Strategy
|
205
|
+
|
206
|
+
### Error Flow
|
207
|
+
|
208
|
+
```mermaid
|
209
|
+
flowchart TD
|
210
|
+
A[Sync Operation] --> B{Success?}
|
211
|
+
|
212
|
+
B -->|Yes| C[Update Local Record]
|
213
|
+
B -->|No| D[Error Occurred]
|
214
|
+
|
215
|
+
D --> E{Has Error Handler?}
|
216
|
+
|
217
|
+
E -->|Yes| F[Call Error Handler]
|
218
|
+
E -->|No| G{Environment?}
|
219
|
+
|
220
|
+
F --> H[Custom Logic]
|
221
|
+
|
222
|
+
G -->|Development| I[Raise Error]
|
223
|
+
G -->|Production| J[Log Error]
|
224
|
+
|
225
|
+
H --> K{In Background Job?}
|
226
|
+
K -->|Yes| L[Retry Logic]
|
227
|
+
K -->|No| M[Complete]
|
228
|
+
|
229
|
+
L --> N{Retry Attempts < 3?}
|
230
|
+
N -->|Yes| O[Wait & Retry]
|
231
|
+
N -->|No| P[Dead Letter Queue]
|
232
|
+
|
233
|
+
O --> A
|
234
|
+
```
|
235
|
+
|
236
|
+
### Retry Strategy with ActiveJob
|
237
|
+
|
238
|
+
```mermaid
|
239
|
+
graph TD
|
240
|
+
A[Job Fails] --> B[Retry Mechanism]
|
241
|
+
B --> C{Attempt 1}
|
242
|
+
C -->|Fails| D[Wait 3 seconds]
|
243
|
+
D --> E{Attempt 2}
|
244
|
+
E -->|Fails| F[Wait 18 seconds]
|
245
|
+
F --> G{Attempt 3}
|
246
|
+
G -->|Fails| H[Job Failed]
|
247
|
+
G -->|Success| I[Complete]
|
248
|
+
E -->|Success| I
|
249
|
+
C -->|Success| I
|
250
|
+
|
251
|
+
style H fill:#f96
|
252
|
+
style I fill:#9f6
|
253
|
+
```
|
254
|
+
|
255
|
+
## Testing Strategy
|
256
|
+
|
257
|
+
### Test Double Architecture
|
258
|
+
|
259
|
+
```mermaid
|
260
|
+
graph TD
|
261
|
+
A[RSpec Test] --> B[Test Helpers]
|
262
|
+
|
263
|
+
B --> C[stub_attio_client]
|
264
|
+
C --> D[Mock Client]
|
265
|
+
C --> E[Mock Records API]
|
266
|
+
|
267
|
+
B --> F[expect_attio_sync]
|
268
|
+
F --> G[Expectation Setup]
|
269
|
+
|
270
|
+
B --> H[with_attio_sync_disabled]
|
271
|
+
H --> I[Temporary Config Change]
|
272
|
+
|
273
|
+
D --> J[No Real API Calls]
|
274
|
+
E --> J
|
275
|
+
|
276
|
+
J --> K[Fast Tests]
|
277
|
+
J --> L[Predictable Results]
|
278
|
+
```
|
279
|
+
|
280
|
+
### Testing Layers
|
281
|
+
|
282
|
+
```mermaid
|
283
|
+
graph TB
|
284
|
+
subgraph "Unit Tests"
|
285
|
+
A[Model Tests]
|
286
|
+
B[Concern Tests]
|
287
|
+
C[Job Tests]
|
288
|
+
end
|
289
|
+
|
290
|
+
subgraph "Integration Tests"
|
291
|
+
D[Sync Flow Tests]
|
292
|
+
E[Batch Processing Tests]
|
293
|
+
end
|
294
|
+
|
295
|
+
subgraph "Test Helpers"
|
296
|
+
F[Stubbed Client]
|
297
|
+
G[Job Assertions]
|
298
|
+
H[Config Helpers]
|
299
|
+
end
|
300
|
+
|
301
|
+
A --> F
|
302
|
+
B --> F
|
303
|
+
C --> G
|
304
|
+
D --> F
|
305
|
+
D --> G
|
306
|
+
E --> F
|
307
|
+
E --> H
|
308
|
+
```
|
309
|
+
|
310
|
+
## Configuration Cascade
|
311
|
+
|
312
|
+
### Configuration Priority
|
313
|
+
|
314
|
+
```mermaid
|
315
|
+
graph TD
|
316
|
+
A[Configuration Sources] --> B[Environment Variables]
|
317
|
+
A --> C[Initializer Config]
|
318
|
+
A --> D[Model-level Options]
|
319
|
+
A --> E[Method-level Options]
|
320
|
+
|
321
|
+
B --> F{Priority}
|
322
|
+
C --> F
|
323
|
+
D --> F
|
324
|
+
E --> F
|
325
|
+
|
326
|
+
F --> G[Final Configuration]
|
327
|
+
|
328
|
+
style E fill:#9f6
|
329
|
+
style D fill:#af9
|
330
|
+
style C fill:#cf9
|
331
|
+
style B fill:#ff9
|
332
|
+
```
|
333
|
+
|
334
|
+
Priority order (highest to lowest):
|
335
|
+
1. Method-level options (e.g., `sync_to_attio_now(force: true)`)
|
336
|
+
2. Model-level options (e.g., `syncs_with_attio 'people', if: :active?`)
|
337
|
+
3. Initializer configuration (e.g., `config.background_sync = true`)
|
338
|
+
4. Environment variables (e.g., `ATTIO_API_KEY`)
|
339
|
+
|
340
|
+
## Data Flow Transformations
|
341
|
+
|
342
|
+
### Transform Pipeline
|
343
|
+
|
344
|
+
```mermaid
|
345
|
+
graph LR
|
346
|
+
A[Raw Model Data] --> B[Attribute Mapping]
|
347
|
+
B --> C[Transform Function]
|
348
|
+
C --> D[Validated Data]
|
349
|
+
D --> E[API Payload]
|
350
|
+
|
351
|
+
B -.->|Example| B1[email: :work_email]
|
352
|
+
C -.->|Example| C1[Add computed fields]
|
353
|
+
D -.->|Example| D1[Remove nil values]
|
354
|
+
E -.->|Example| E1[JSON structure]
|
355
|
+
```
|
356
|
+
|
357
|
+
### Callback Chain
|
358
|
+
|
359
|
+
```mermaid
|
360
|
+
sequenceDiagram
|
361
|
+
participant Model
|
362
|
+
participant Callbacks
|
363
|
+
participant Sync
|
364
|
+
participant API
|
365
|
+
|
366
|
+
Model->>Callbacks: before_attio_sync
|
367
|
+
Callbacks->>Callbacks: prepare_data
|
368
|
+
Callbacks->>Sync: proceed with sync
|
369
|
+
Sync->>Sync: transform_attributes
|
370
|
+
Sync->>API: send data
|
371
|
+
API-->>Sync: response
|
372
|
+
Sync->>Callbacks: after_attio_sync
|
373
|
+
Callbacks->>Callbacks: log_sync
|
374
|
+
Callbacks->>Model: complete
|
375
|
+
```
|
376
|
+
|
377
|
+
## Performance Considerations
|
378
|
+
|
379
|
+
### Optimization Strategies
|
380
|
+
|
381
|
+
```mermaid
|
382
|
+
graph TD
|
383
|
+
A[Performance Optimizations] --> B[Batch Processing]
|
384
|
+
A --> C[Background Jobs]
|
385
|
+
A --> D[Connection Pooling]
|
386
|
+
A --> E[Smart Retries]
|
387
|
+
|
388
|
+
B --> F[Reduce API Calls]
|
389
|
+
C --> G[Non-blocking Operations]
|
390
|
+
D --> H[Reuse Connections]
|
391
|
+
E --> I[Exponential Backoff]
|
392
|
+
|
393
|
+
F --> J[Better Performance]
|
394
|
+
G --> J
|
395
|
+
H --> J
|
396
|
+
I --> J
|
397
|
+
```
|
398
|
+
|
399
|
+
### Load Distribution
|
400
|
+
|
401
|
+
```mermaid
|
402
|
+
graph LR
|
403
|
+
A[100 Records to Sync] --> B[BatchSync]
|
404
|
+
B --> C[Batch 1: Records 1-25]
|
405
|
+
B --> D[Batch 2: Records 26-50]
|
406
|
+
B --> E[Batch 3: Records 51-75]
|
407
|
+
B --> F[Batch 4: Records 76-100]
|
408
|
+
|
409
|
+
C --> G[Job Queue]
|
410
|
+
D --> G
|
411
|
+
E --> G
|
412
|
+
F --> G
|
413
|
+
|
414
|
+
G --> H[Worker 1]
|
415
|
+
G --> I[Worker 2]
|
416
|
+
G --> J[Worker N]
|
417
|
+
|
418
|
+
style G fill:#9cf
|
419
|
+
```
|
420
|
+
|
421
|
+
## Best Practices
|
422
|
+
|
423
|
+
### Recommended Patterns
|
424
|
+
|
425
|
+
1. **Use Background Sync for Production**
|
426
|
+
- Prevents blocking web requests
|
427
|
+
- Provides automatic retry on failure
|
428
|
+
- Better user experience
|
429
|
+
|
430
|
+
2. **Implement Error Handlers**
|
431
|
+
- Log errors to monitoring services
|
432
|
+
- Gracefully handle API downtime
|
433
|
+
- Notify administrators of issues
|
434
|
+
|
435
|
+
3. **Optimize Attribute Mapping**
|
436
|
+
- Only sync necessary fields
|
437
|
+
- Use transforms to reduce payload size
|
438
|
+
- Cache computed values when possible
|
439
|
+
|
440
|
+
4. **Test Thoroughly**
|
441
|
+
- Use provided test helpers
|
442
|
+
- Mock external API calls
|
443
|
+
- Test error scenarios
|
444
|
+
|
445
|
+
5. **Monitor Performance**
|
446
|
+
- Track sync success rates
|
447
|
+
- Monitor job queue depth
|
448
|
+
- Alert on repeated failures
|
data/Gemfile
CHANGED
@@ -17,7 +17,7 @@ group :development, :test do
|
|
17
17
|
gem "rspec-rails", "~> 6.0"
|
18
18
|
gem "rubocop", "~> 1.50"
|
19
19
|
gem "rubocop-rails", "~> 2.19"
|
20
|
-
gem "rubocop-rspec", "~>
|
20
|
+
gem "rubocop-rspec", "~> 3.6"
|
21
21
|
gem "simplecov", "~> 0.22"
|
22
22
|
gem "simplecov-console", "~> 0.9"
|
23
23
|
gem "sqlite3", "~> 1.4"
|
@@ -0,0 +1,209 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attio
|
4
|
+
module Rails
|
5
|
+
module RSpec
|
6
|
+
# RSpec helper methods for testing Attio Rails integration
|
7
|
+
#
|
8
|
+
# @example Including in your specs
|
9
|
+
# RSpec.configure do |config|
|
10
|
+
# config.include Attio::Rails::RSpec::Helpers
|
11
|
+
# end
|
12
|
+
#
|
13
|
+
# @example Basic usage
|
14
|
+
# it 'syncs to Attio' do
|
15
|
+
# stub_attio_client
|
16
|
+
#
|
17
|
+
# user = User.create!(email: 'test@example.com')
|
18
|
+
# expect(user.attio_record_id).to eq('attio-test-id')
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# @example Testing with expectations
|
22
|
+
# it 'sends correct attributes' do
|
23
|
+
# expect_attio_sync(
|
24
|
+
# object: 'people',
|
25
|
+
# attributes: { email: 'test@example.com' }
|
26
|
+
# ) do
|
27
|
+
# User.create!(email: 'test@example.com')
|
28
|
+
# end
|
29
|
+
# end
|
30
|
+
module Helpers
|
31
|
+
# Stub the Attio client and records API
|
32
|
+
#
|
33
|
+
# @return [Hash{Symbol => RSpec::Mocks::Double}] Hash with :client and :records doubles
|
34
|
+
#
|
35
|
+
# @example
|
36
|
+
# stubs = stub_attio_client
|
37
|
+
# allow(stubs[:records]).to receive(:create).and_return(response)
|
38
|
+
def stub_attio_client
|
39
|
+
client = instance_double(Attio::Client)
|
40
|
+
records = instance_double(Attio::Resources::Records)
|
41
|
+
|
42
|
+
allow(Attio::Rails).to receive(:client).and_return(client)
|
43
|
+
allow(client).to receive(:records).and_return(records)
|
44
|
+
|
45
|
+
{ client: client, records: records }
|
46
|
+
end
|
47
|
+
|
48
|
+
# Stub Attio create operations
|
49
|
+
#
|
50
|
+
# @param response [Hash] Custom response to return (default: { "data" => { "id" => "attio-test-id" } })
|
51
|
+
# @return [Hash{Symbol => RSpec::Mocks::Double}] Hash with :client and :records doubles
|
52
|
+
#
|
53
|
+
# @example
|
54
|
+
# stub_attio_create
|
55
|
+
# User.create!(email: 'test@example.com')
|
56
|
+
#
|
57
|
+
# @example With custom response
|
58
|
+
# stub_attio_create('data' => { 'id' => 'custom-id' })
|
59
|
+
def stub_attio_create(response = { "data" => { "id" => "attio-test-id" } })
|
60
|
+
stubs = stub_attio_client
|
61
|
+
allow(stubs[:records]).to receive(:create).and_return(response)
|
62
|
+
stubs
|
63
|
+
end
|
64
|
+
|
65
|
+
# Stub Attio update operations
|
66
|
+
#
|
67
|
+
# @param response [Hash] Custom response to return
|
68
|
+
# @return [Hash{Symbol => RSpec::Mocks::Double}] Hash with :client and :records doubles
|
69
|
+
#
|
70
|
+
# @example
|
71
|
+
# stub_attio_update
|
72
|
+
# user.update!(name: 'New Name')
|
73
|
+
def stub_attio_update(response = { "data" => { "id" => "attio-test-id" } })
|
74
|
+
stubs = stub_attio_client
|
75
|
+
allow(stubs[:records]).to receive(:update).and_return(response)
|
76
|
+
stubs
|
77
|
+
end
|
78
|
+
|
79
|
+
# Stub Attio delete operations
|
80
|
+
#
|
81
|
+
# @param response [Hash] Custom response to return
|
82
|
+
# @return [Hash{Symbol => RSpec::Mocks::Double}] Hash with :client and :records doubles
|
83
|
+
#
|
84
|
+
# @example
|
85
|
+
# stub_attio_delete
|
86
|
+
# user.destroy!
|
87
|
+
def stub_attio_delete(response = { "data" => { "deleted" => true } })
|
88
|
+
stubs = stub_attio_client
|
89
|
+
allow(stubs[:records]).to receive(:delete).and_return(response)
|
90
|
+
stubs
|
91
|
+
end
|
92
|
+
|
93
|
+
# Expect a sync to Attio with specific parameters
|
94
|
+
#
|
95
|
+
# @param object [String] Expected Attio object type
|
96
|
+
# @param attributes [Hash, nil] Expected attributes (nil to match any)
|
97
|
+
# @yield Block to execute that should trigger the sync
|
98
|
+
# @return [Hash{Symbol => RSpec::Mocks::Double}] Hash with :client and :records doubles
|
99
|
+
#
|
100
|
+
# @example
|
101
|
+
# expect_attio_sync(object: 'people', attributes: { email: 'test@example.com' }) do
|
102
|
+
# User.create!(email: 'test@example.com')
|
103
|
+
# end
|
104
|
+
def expect_attio_sync(object:, attributes: nil)
|
105
|
+
stubs = stub_attio_client
|
106
|
+
|
107
|
+
if attributes
|
108
|
+
expect(stubs[:records]).to receive(:create).with(
|
109
|
+
object: object,
|
110
|
+
data: { values: attributes }
|
111
|
+
).and_return({ "data" => { "id" => "attio-test-id" } })
|
112
|
+
else
|
113
|
+
expect(stubs[:records]).to receive(:create).with(
|
114
|
+
hash_including(object: object)
|
115
|
+
).and_return({ "data" => { "id" => "attio-test-id" } })
|
116
|
+
end
|
117
|
+
|
118
|
+
yield if block_given?
|
119
|
+
|
120
|
+
stubs
|
121
|
+
end
|
122
|
+
|
123
|
+
# Expect no sync to Attio
|
124
|
+
#
|
125
|
+
# @yield Block to execute that should not trigger any sync
|
126
|
+
# @return [Hash{Symbol => RSpec::Mocks::Double}] Hash with :client and :records doubles
|
127
|
+
#
|
128
|
+
# @example
|
129
|
+
# expect_no_attio_sync do
|
130
|
+
# with_attio_sync_disabled do
|
131
|
+
# User.create!(email: 'test@example.com')
|
132
|
+
# end
|
133
|
+
# end
|
134
|
+
def expect_no_attio_sync
|
135
|
+
stubs = stub_attio_client
|
136
|
+
|
137
|
+
expect(stubs[:records]).not_to receive(:create)
|
138
|
+
expect(stubs[:records]).not_to receive(:update)
|
139
|
+
|
140
|
+
yield if block_given?
|
141
|
+
|
142
|
+
stubs
|
143
|
+
end
|
144
|
+
|
145
|
+
# Temporarily disable Attio syncing
|
146
|
+
#
|
147
|
+
# @yield Block to execute with syncing disabled
|
148
|
+
#
|
149
|
+
# @example
|
150
|
+
# with_attio_sync_disabled do
|
151
|
+
# User.create!(email: 'test@example.com') # Won't sync
|
152
|
+
# end
|
153
|
+
def with_attio_sync_disabled
|
154
|
+
original_value = Attio::Rails.configuration.sync_enabled
|
155
|
+
Attio::Rails.configure { |c| c.sync_enabled = false }
|
156
|
+
|
157
|
+
yield
|
158
|
+
ensure
|
159
|
+
Attio::Rails.configure { |c| c.sync_enabled = original_value }
|
160
|
+
end
|
161
|
+
|
162
|
+
# Temporarily enable background sync
|
163
|
+
#
|
164
|
+
# @yield Block to execute with background sync enabled
|
165
|
+
#
|
166
|
+
# @example
|
167
|
+
# with_attio_background_sync do
|
168
|
+
# User.create!(email: 'test@example.com') # Will sync in background
|
169
|
+
# end
|
170
|
+
# expect(attio_sync_jobs.size).to eq(1)
|
171
|
+
def with_attio_background_sync
|
172
|
+
original_value = Attio::Rails.configuration.background_sync
|
173
|
+
Attio::Rails.configure { |c| c.background_sync = true }
|
174
|
+
|
175
|
+
yield
|
176
|
+
ensure
|
177
|
+
Attio::Rails.configure { |c| c.background_sync = original_value }
|
178
|
+
end
|
179
|
+
|
180
|
+
# Get all enqueued AttioSyncJob jobs
|
181
|
+
#
|
182
|
+
# @return [Array<Hash>] Array of enqueued job hashes
|
183
|
+
#
|
184
|
+
# @example
|
185
|
+
# User.create!(email: 'test@example.com')
|
186
|
+
# expect(attio_sync_jobs.size).to eq(1)
|
187
|
+
# expect(attio_sync_jobs.first[:args]).to include('model_name' => 'User')
|
188
|
+
def attio_sync_jobs
|
189
|
+
ActiveJob::Base.queue_adapter.enqueued_jobs.select do |job|
|
190
|
+
job[:job] == AttioSyncJob
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
# Clear all enqueued AttioSyncJob jobs
|
195
|
+
#
|
196
|
+
# @return [Array<Hash>] The deleted jobs
|
197
|
+
#
|
198
|
+
# @example
|
199
|
+
# clear_attio_sync_jobs
|
200
|
+
# expect(attio_sync_jobs).to be_empty
|
201
|
+
def clear_attio_sync_jobs
|
202
|
+
ActiveJob::Base.queue_adapter.enqueued_jobs.delete_if do |job|
|
203
|
+
job[:job] == AttioSyncJob
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
@@ -0,0 +1,145 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attio
|
4
|
+
module Rails
|
5
|
+
module RSpec
|
6
|
+
module Matchers
|
7
|
+
::RSpec::Matchers.define :sync_to_attio do |expected|
|
8
|
+
match do |actual|
|
9
|
+
return false unless actual.class.respond_to?(:attio_object_type)
|
10
|
+
|
11
|
+
if expected
|
12
|
+
actual.class.attio_object_type == expected[:object] || expected[:object_type]
|
13
|
+
else
|
14
|
+
actual.class.attio_object_type.present?
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
failure_message do |actual|
|
19
|
+
if actual.class.respond_to?(:attio_object_type)
|
20
|
+
expected_obj = expected[:object] || expected[:object_type]
|
21
|
+
actual_obj = actual.class.attio_object_type
|
22
|
+
"expected #{actual.class} to sync to Attio object '#{expected_obj}' but syncs to '#{actual_obj}'"
|
23
|
+
else
|
24
|
+
"expected #{actual.class} to include Attio::Rails::Concerns::Syncable"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
failure_message_when_negated do |actual|
|
29
|
+
"expected #{actual.class} not to sync to Attio"
|
30
|
+
end
|
31
|
+
|
32
|
+
description do
|
33
|
+
"sync to Attio"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
::RSpec::Matchers.define :have_attio_attribute do |attio_attr|
|
38
|
+
match do |actual|
|
39
|
+
return false unless actual.class.respond_to?(:attio_attribute_mapping)
|
40
|
+
|
41
|
+
mapping = actual.class.attio_attribute_mapping
|
42
|
+
if @mapped_to
|
43
|
+
mapping[attio_attr] == @mapped_to
|
44
|
+
else
|
45
|
+
mapping.key?(attio_attr)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
chain :mapped_to do |local_attr|
|
50
|
+
@mapped_to = local_attr
|
51
|
+
end
|
52
|
+
|
53
|
+
failure_message do |actual|
|
54
|
+
if actual.class.respond_to?(:attio_attribute_mapping)
|
55
|
+
mapping = actual.class.attio_attribute_mapping
|
56
|
+
if @mapped_to
|
57
|
+
actual_mapping = mapping[attio_attr]
|
58
|
+
"expected #{actual.class} to map Attio attribute '#{attio_attr}' to '#{@mapped_to}' " \
|
59
|
+
"but it maps to '#{actual_mapping}'"
|
60
|
+
else
|
61
|
+
available_attrs = mapping.keys.join(", ")
|
62
|
+
"expected #{actual.class} to have Attio attribute '#{attio_attr}' but has #{available_attrs}"
|
63
|
+
end
|
64
|
+
else
|
65
|
+
"expected #{actual.class} to include Attio::Rails::Concerns::Syncable"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
description do
|
70
|
+
if @mapped_to
|
71
|
+
"have Attio attribute '#{attio_attr}' mapped to '#{@mapped_to}'"
|
72
|
+
else
|
73
|
+
"have Attio attribute '#{attio_attr}'"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
::RSpec::Matchers.define :enqueue_attio_sync_job do # rubocop:disable Metrics/BlockLength
|
79
|
+
supports_block_expectations
|
80
|
+
|
81
|
+
match do |block|
|
82
|
+
initial_jobs = attio_sync_jobs.dup
|
83
|
+
block.call
|
84
|
+
new_jobs = attio_sync_jobs - initial_jobs
|
85
|
+
@actual_count = new_jobs.size
|
86
|
+
|
87
|
+
if @expected_count
|
88
|
+
@actual_count == @expected_count
|
89
|
+
elsif @expected_action
|
90
|
+
new_jobs.any? { |job| job[:args].first["action"]["value"] == @expected_action.to_s }
|
91
|
+
else
|
92
|
+
@actual_count > 0
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
chain :with_action do |action|
|
97
|
+
@expected_action = action
|
98
|
+
end
|
99
|
+
|
100
|
+
chain :exactly do |count|
|
101
|
+
@expected_count = count
|
102
|
+
end
|
103
|
+
|
104
|
+
failure_message do
|
105
|
+
build_failure_message
|
106
|
+
end
|
107
|
+
|
108
|
+
failure_message_when_negated do
|
109
|
+
"expected not to enqueue AttioSyncJob but #{@actual_count} were enqueued"
|
110
|
+
end
|
111
|
+
|
112
|
+
description do
|
113
|
+
build_description
|
114
|
+
end
|
115
|
+
|
116
|
+
private def build_failure_message
|
117
|
+
if @expected_count
|
118
|
+
"expected to enqueue #{@expected_count} AttioSyncJob(s) but enqueued #{@actual_count}"
|
119
|
+
elsif @expected_action
|
120
|
+
"expected to enqueue AttioSyncJob with action '#{@expected_action}'"
|
121
|
+
else
|
122
|
+
"expected to enqueue AttioSyncJob but none were enqueued"
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
private def build_description
|
127
|
+
if @expected_count
|
128
|
+
"enqueue #{@expected_count} AttioSyncJob(s)"
|
129
|
+
elsif @expected_action
|
130
|
+
"enqueue AttioSyncJob with action '#{@expected_action}'"
|
131
|
+
else
|
132
|
+
"enqueue AttioSyncJob"
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
private def attio_sync_jobs
|
137
|
+
ActiveJob::Base.queue_adapter.enqueued_jobs.select do |job|
|
138
|
+
job[:job] == AttioSyncJob
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "attio/rails/rspec/helpers"
|
4
|
+
require "attio/rails/rspec/matchers"
|
5
|
+
|
6
|
+
RSpec.configure do |config|
|
7
|
+
config.include Attio::Rails::RSpec::Helpers, type: :model
|
8
|
+
config.include Attio::Rails::RSpec::Matchers, type: :model
|
9
|
+
end
|
data/lib/attio/rails/version.rb
CHANGED
metadata
CHANGED
@@ -1,13 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: attio-rails
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ernest Sim
|
8
|
+
autorequire:
|
8
9
|
bindir: exe
|
9
10
|
cert_chain: []
|
10
|
-
date:
|
11
|
+
date: 2025-08-11 00:00:00.000000000 Z
|
11
12
|
dependencies:
|
12
13
|
- !ruby/object:Gem::Dependency
|
13
14
|
name: attio
|
@@ -59,9 +60,9 @@ extra_rdoc_files: []
|
|
59
60
|
files:
|
60
61
|
- ".codecov.yml"
|
61
62
|
- ".github/dependabot.yml"
|
63
|
+
- ".github/workflows/build-and-publish.yml"
|
62
64
|
- ".github/workflows/ci.yml"
|
63
65
|
- ".github/workflows/docs.yml"
|
64
|
-
- ".github/workflows/release.yml"
|
65
66
|
- ".gitignore"
|
66
67
|
- ".rspec"
|
67
68
|
- ".rubocop.yml"
|
@@ -69,6 +70,7 @@ files:
|
|
69
70
|
- ".yardopts"
|
70
71
|
- CHANGELOG.md
|
71
72
|
- CODE_OF_CONDUCT.md
|
73
|
+
- CONCEPTS.md
|
72
74
|
- CONTRIBUTING.md
|
73
75
|
- Gemfile
|
74
76
|
- LICENSE.txt
|
@@ -83,6 +85,9 @@ files:
|
|
83
85
|
- lib/attio/rails/concerns/syncable.rb
|
84
86
|
- lib/attio/rails/configuration.rb
|
85
87
|
- lib/attio/rails/railtie.rb
|
88
|
+
- lib/attio/rails/rspec.rb
|
89
|
+
- lib/attio/rails/rspec/helpers.rb
|
90
|
+
- lib/attio/rails/rspec/matchers.rb
|
86
91
|
- lib/attio/rails/version.rb
|
87
92
|
- lib/generators/attio/install/install_generator.rb
|
88
93
|
- lib/generators/attio/install/templates/README.md
|
@@ -99,6 +104,7 @@ metadata:
|
|
99
104
|
source_code_uri: https://github.com/idl3/attio-rails
|
100
105
|
changelog_uri: https://github.com/idl3/attio-rails/blob/main/CHANGELOG.md
|
101
106
|
documentation_uri: https://idl3.github.io/attio-rails
|
107
|
+
post_install_message:
|
102
108
|
rdoc_options: []
|
103
109
|
require_paths:
|
104
110
|
- lib
|
@@ -113,7 +119,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
113
119
|
- !ruby/object:Gem::Version
|
114
120
|
version: '0'
|
115
121
|
requirements: []
|
116
|
-
rubygems_version: 3.
|
122
|
+
rubygems_version: 3.4.19
|
123
|
+
signing_key:
|
117
124
|
specification_version: 4
|
118
125
|
summary: Rails integration for the Attio API client
|
119
126
|
test_files: []
|