cloudflare-d1 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,459 @@
1
+ # Roda + Cloudflare D1 + Containers Example
2
+
3
+ This example demonstrates deploying a Roda application using Cloudflare Containers with a D1 database backend.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ Internet Request
9
+
10
+ Cloudflare Worker (worker.js)
11
+
12
+ Container Instance (Durable Object)
13
+
14
+ Roda App (Ruby)
15
+
16
+ Cloudflare D1 Database (SQLite via REST API)
17
+ ```
18
+
19
+ ## Features
20
+
21
+ - **Roda** - Fast, simple Ruby web framework
22
+ - **Sequel** - Database toolkit for Ruby
23
+ - **Cloudflare D1** - SQLite database at the edge
24
+ - **Cloudflare Containers** - Run containerized apps on Workers
25
+ - **RESTful API** - Full CRUD operations for users
26
+
27
+ ## Prerequisites
28
+
29
+ 1. **Cloudflare Account** with Workers Paid plan
30
+ 2. **Wrangler CLI**:
31
+ ```bash
32
+ npm install -g wrangler
33
+ wrangler login
34
+ ```
35
+ 3. **Docker** running locally
36
+ 4. **Ruby 3.2+** (for local development)
37
+
38
+ ## Setup
39
+
40
+ ### 1. Create D1 Database
41
+
42
+ ```bash
43
+ # Create a new D1 database
44
+ wrangler d1 create roda-api-db
45
+
46
+ # Note the database ID from the output
47
+ # Add it to your secrets (see step 3)
48
+ ```
49
+
50
+ ### 2. Install Dependencies
51
+
52
+ ```bash
53
+ bundle install
54
+ ```
55
+
56
+ ### 3. Configure Secrets
57
+
58
+ Set your Cloudflare credentials and database ID as Worker secrets:
59
+
60
+ ```bash
61
+ # Your Cloudflare account ID
62
+ wrangler secret put CLOUDFLARE_ACCOUNT_ID
63
+ # Enter: your_account_id
64
+
65
+ # Your Cloudflare API token (needs D1 permissions)
66
+ wrangler secret put CLOUDFLARE_API_TOKEN
67
+ # Enter: your_api_token
68
+
69
+ # Your D1 database UUID (from step 1)
70
+ wrangler secret put DATABASE_ID
71
+ # Enter: your_database_uuid
72
+ ```
73
+
74
+ These secrets are stored in the Worker and passed to the container at runtime via `worker.js`.
75
+
76
+ **To get your credentials:**
77
+ - Account ID: `wrangler whoami` or Cloudflare dashboard
78
+ - API Token: Dashboard → My Profile → API Tokens → Create Token
79
+ - Template: "Edit Cloudflare Workers"
80
+ - Permissions: Include D1 Database permissions
81
+
82
+ ### 4. Update wrangler.jsonc
83
+
84
+ Edit `wrangler.jsonc` and update:
85
+ - `name`: Must be unique across Cloudflare (change `roda-d1-api` to something unique)
86
+ - `routes`: Optional, configure custom domain
87
+
88
+ ## Local Development
89
+
90
+ The app uses **local SQLite** for development (since D1 is only accessible via Cloudflare's API).
91
+
92
+ ### Run Locally
93
+
94
+ ```bash
95
+ # Install dependencies
96
+ bundle install
97
+
98
+ # Run the app (uses local SQLite)
99
+ ruby app.rb
100
+ ```
101
+
102
+ **No environment variables needed** - it automatically uses `db/development.db`.
103
+
104
+ ### Test Locally
105
+
106
+ ```bash
107
+ # Create a user
108
+ curl -X POST http://localhost:8080/users \
109
+ -H "Content-Type: application/json" \
110
+ -d '{"name": "John Doe", "email": "john@example.com"}'
111
+
112
+ # List users
113
+ curl http://localhost:8080/users
114
+
115
+ # Get user by ID
116
+ curl http://localhost:8080/users/1
117
+
118
+ # Update user
119
+ curl -X PUT http://localhost:8080/users/1 \
120
+ -H "Content-Type: application/json" \
121
+ -d '{"name": "Jane Doe"}'
122
+
123
+ # Delete user
124
+ curl -X DELETE http://localhost:8080/users/1
125
+ ```
126
+
127
+ ### How It Works
128
+
129
+ The app detects the environment:
130
+
131
+ ```ruby
132
+ if ENV["RACK_ENV"] == "production"
133
+ # Use Cloudflare D1 (in container)
134
+ DB = Sequel.connect(adapter: :cloudflare_d1, ...)
135
+ else
136
+ # Use local SQLite (in development)
137
+ DB = Sequel.connect("sqlite://db/development.db")
138
+ end
139
+ ```
140
+
141
+ **In production** (container): Uses D1 via HTTP API
142
+ **In development** (local): Uses SQLite file directly
143
+
144
+ Same migrations work for both!
145
+
146
+ ### Alternative: Test Against Real D1
147
+
148
+ If you need to test against the actual D1 database:
149
+
150
+ ```bash
151
+ # Set environment variables
152
+ export RACK_ENV=production
153
+ export CLOUDFLARE_ACCOUNT_ID=your_account_id
154
+ export CLOUDFLARE_API_TOKEN=your_api_token
155
+ export DATABASE_ID=your_database_uuid
156
+
157
+ # Run locally (will use D1 API)
158
+ ruby app.rb
159
+ ```
160
+
161
+ **Note:** This makes real HTTP requests to Cloudflare's API on every query (slow for development).
162
+
163
+ ## Deployment
164
+
165
+ ### 1. Deploy to Cloudflare
166
+
167
+ ```bash
168
+ # Make sure Docker is running
169
+ docker info
170
+
171
+ # Deploy the application
172
+ wrangler deploy
173
+ ```
174
+
175
+ **Note:** First deployment takes several minutes as Cloudflare provisions container infrastructure.
176
+
177
+ **Migrations run automatically** when the container starts. No manual migration step needed.
178
+
179
+ ### 2. Check Status
180
+
181
+ ```bash
182
+ # List containers
183
+ wrangler containers list
184
+
185
+ # List container images
186
+ wrangler containers images list
187
+
188
+ # View logs
189
+ wrangler tail
190
+ ```
191
+
192
+ ### 3. Test Deployed App
193
+
194
+ ```bash
195
+ # Replace with your deployed URL
196
+ export API_URL=https://roda-d1-api.your-subdomain.workers.dev
197
+
198
+ # Health check
199
+ curl $API_URL/health
200
+
201
+ # Create user
202
+ curl -X POST $API_URL/users \
203
+ -H "Content-Type: application/json" \
204
+ -d '{"name": "Alice", "email": "alice@example.com"}'
205
+
206
+ # List users
207
+ curl $API_URL/users
208
+
209
+ # Get user
210
+ curl $API_URL/users/1
211
+ ```
212
+
213
+ ## API Endpoints
214
+
215
+ ### Root
216
+ ```
217
+ GET /
218
+ ```
219
+ Returns API information and available endpoints.
220
+
221
+ ### Users
222
+
223
+ #### List all users
224
+ ```
225
+ GET /users
226
+ ```
227
+
228
+ Response:
229
+ ```json
230
+ {
231
+ "users": [
232
+ {
233
+ "id": 1,
234
+ "name": "John Doe",
235
+ "email": "john@example.com",
236
+ "created_at": "2024-01-01T00:00:00Z",
237
+ "updated_at": "2024-01-01T00:00:00Z"
238
+ }
239
+ ]
240
+ }
241
+ ```
242
+
243
+ #### Get user by ID
244
+ ```
245
+ GET /users/:id
246
+ ```
247
+
248
+ Response:
249
+ ```json
250
+ {
251
+ "user": {
252
+ "id": 1,
253
+ "name": "John Doe",
254
+ "email": "john@example.com",
255
+ "created_at": "2024-01-01T00:00:00Z",
256
+ "updated_at": "2024-01-01T00:00:00Z"
257
+ }
258
+ }
259
+ ```
260
+
261
+ #### Create user
262
+ ```
263
+ POST /users
264
+ Content-Type: application/json
265
+
266
+ {
267
+ "name": "John Doe",
268
+ "email": "john@example.com"
269
+ }
270
+ ```
271
+
272
+ Response: `201 Created`
273
+ ```json
274
+ {
275
+ "user": {
276
+ "id": 1,
277
+ "name": "John Doe",
278
+ "email": "john@example.com",
279
+ "created_at": "2024-01-01T00:00:00Z",
280
+ "updated_at": "2024-01-01T00:00:00Z"
281
+ }
282
+ }
283
+ ```
284
+
285
+ #### Update user
286
+ ```
287
+ PUT /users/:id
288
+ Content-Type: application/json
289
+
290
+ {
291
+ "name": "Jane Doe",
292
+ "email": "jane@example.com"
293
+ }
294
+ ```
295
+
296
+ Response:
297
+ ```json
298
+ {
299
+ "user": {
300
+ "id": 1,
301
+ "name": "Jane Doe",
302
+ "email": "jane@example.com",
303
+ "created_at": "2024-01-01T00:00:00Z",
304
+ "updated_at": "2024-01-01T00:00:01Z"
305
+ }
306
+ }
307
+ ```
308
+
309
+ #### Delete user
310
+ ```
311
+ DELETE /users/:id
312
+ ```
313
+
314
+ Response: `204 No Content`
315
+
316
+ ## Migrations
317
+
318
+ Migrations run automatically on container boot. To add a new migration:
319
+
320
+ 1. Create a file in `db/migrations/` with format `00X_description.rb`:
321
+ ```ruby
322
+ # db/migrations/002_add_age_to_users.rb
323
+ Sequel.migration do
324
+ up do
325
+ alter_table(:users) do
326
+ add_column :age, Integer
327
+ end
328
+ end
329
+
330
+ down do
331
+ alter_table(:users) do
332
+ drop_column :age
333
+ end
334
+ end
335
+ end
336
+ ```
337
+
338
+ 2. Deploy:
339
+ ```bash
340
+ wrangler deploy # Migration runs on boot
341
+ ```
342
+
343
+ ## Project Structure
344
+
345
+ ```
346
+ examples/roda/
347
+ ├── Dockerfile # Container image definition
348
+ ├── Gemfile # Ruby dependencies
349
+ ├── Gemfile.lock # Locked dependency versions
350
+ ├── app.rb # Roda application
351
+ ├── worker.js # Cloudflare Worker (routes to container)
352
+ ├── wrangler.jsonc # Cloudflare deployment configuration
353
+ ├── db/
354
+ │ └── migrations/ # Database migrations (run on boot)
355
+ └── README.md # This file
356
+ ```
357
+
358
+ ## Configuration
359
+
360
+ ### Container Settings (wrangler.jsonc)
361
+
362
+ - **max_instances**: Maximum concurrent container instances (default: 10)
363
+ - **instance_type**: Container size - `lite`, `basic`, `standard-1` through `standard-4`
364
+ - **sleepAfter**: How long to keep container alive after inactivity (default: "10m")
365
+
366
+ ### Worker Routing Strategies
367
+
368
+ The `worker.js` file uses a single stateful container instance:
369
+
370
+ ```javascript
371
+ const containerStub = env.RODA_CONTAINER.getByName("main");
372
+ ```
373
+
374
+ **Alternative strategies:**
375
+
376
+ 1. **Load balanced (stateless)**:
377
+ ```javascript
378
+ const id = Math.floor(Math.random() * 3);
379
+ const containerStub = env.RODA_CONTAINER.getById(id.toString());
380
+ ```
381
+
382
+ 2. **Session-based (sticky sessions)**:
383
+ ```javascript
384
+ const sessionId = request.headers.get("X-Session-ID") || "default";
385
+ const containerStub = env.RODA_CONTAINER.getByName(sessionId);
386
+ ```
387
+
388
+ ## Troubleshooting
389
+
390
+ ### Container not starting
391
+
392
+ Check logs:
393
+ ```bash
394
+ wrangler tail
395
+ ```
396
+
397
+ Verify Docker is running:
398
+ ```bash
399
+ docker info
400
+ ```
401
+
402
+ ### Database connection errors
403
+
404
+ Verify secrets are set:
405
+ ```bash
406
+ wrangler secret list
407
+ ```
408
+
409
+ Test D1 connection:
410
+ ```bash
411
+ wrangler d1 execute roda-api-db --command="SELECT 1"
412
+ ```
413
+
414
+ ### Build errors
415
+
416
+ Clear Docker cache:
417
+ ```bash
418
+ docker system prune -a
419
+ ```
420
+
421
+ Rebuild:
422
+ ```bash
423
+ wrangler deploy
424
+ ```
425
+
426
+ ### "Class not found" errors
427
+
428
+ Make sure:
429
+ - `class_name` matches between `containers` and `durable_objects.bindings`
430
+ - `new_sqlite_classes` includes the class name
431
+ - You've run `wrangler deploy` after changing configuration
432
+
433
+ ## Development Tips
434
+
435
+ 1. **Use local Ruby for faster iteration** - Test changes locally before deploying
436
+ 2. **Monitor logs** - Keep `wrangler tail` running during development
437
+ 3. **Version your migrations** - Use Sequel migrations for database changes
438
+ 4. **Test error handling** - Containers can fail, handle errors gracefully
439
+ 5. **Monitor costs** - Containers use compute time, monitor usage in dashboard
440
+
441
+ ## Limitations
442
+
443
+ - Container cold starts take a few seconds
444
+ - Maximum 10 concurrent instances (configurable)
445
+ - No persistent filesystem (use D1 for data)
446
+ - Must use linux/amd64 architecture
447
+ - Requires Workers Paid plan
448
+
449
+ ## Learn More
450
+
451
+ - [Cloudflare Containers](https://developers.cloudflare.com/containers/)
452
+ - [Cloudflare D1](https://developers.cloudflare.com/d1/)
453
+ - [Roda Framework](http://roda.jeremyevans.net/)
454
+ - [Sequel ORM](https://sequel.jeremyevans.net/)
455
+ - [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/)
456
+
457
+ ## License
458
+
459
+ MIT
@@ -0,0 +1,165 @@
1
+ require "roda"
2
+ require "sequel"
3
+ require "sequel/adapters/cloudflare_d1"
4
+ require "json"
5
+
6
+ # Connect to database
7
+ # In development: Use local SQLite
8
+ # In production: Use Cloudflare D1 via API
9
+ if ENV["RACK_ENV"] == "production"
10
+ DB = Sequel.connect(
11
+ adapter: :cloudflare_d1,
12
+ account_id: ENV["CLOUDFLARE_ACCOUNT_ID"],
13
+ api_token: ENV["CLOUDFLARE_API_TOKEN"],
14
+ database: ENV["DATABASE_ID"]
15
+ )
16
+ else
17
+ # Local development with SQLite
18
+ DB = Sequel.connect("sqlite://db/development.db")
19
+ end
20
+
21
+ # Run migrations on startup
22
+ Sequel.extension :migration
23
+ puts "Running migrations..."
24
+ Sequel::Migrator.run(DB, File.join(__dir__, "db/migrations"))
25
+ puts "Migrations complete!"
26
+
27
+ class App < Roda
28
+ plugin :json
29
+ plugin :all_verbs
30
+ plugin :status_handler
31
+ plugin :error_handler
32
+
33
+ status_handler(404) do
34
+ { error: "Not found" }
35
+ end
36
+
37
+ error_handler do |e|
38
+ { error: e.message }
39
+ end
40
+
41
+ route do |r|
42
+ r.root do
43
+ {
44
+ message: "Cloudflare D1 + Roda API",
45
+ version: "1.0.0",
46
+ endpoints: {
47
+ users: {
48
+ list: "GET /users",
49
+ show: "GET /users/:id",
50
+ create: "POST /users",
51
+ update: "PUT /users/:id",
52
+ delete: "DELETE /users/:id"
53
+ }
54
+ }
55
+ }
56
+ end
57
+
58
+ r.on "users" do
59
+ r.is do
60
+ # GET /users - List all users
61
+ r.get do
62
+ users = DB[:users].all
63
+ { users: users }
64
+ end
65
+
66
+ # POST /users - Create a new user
67
+ r.post do
68
+ data = JSON.parse(r.body.read)
69
+
70
+ unless data["name"] && data["email"]
71
+ response.status = 400
72
+ next { error: "Name and email are required" }
73
+ end
74
+
75
+ begin
76
+ user_id = DB[:users].insert(
77
+ name: data["name"],
78
+ email: data["email"],
79
+ created_at: Time.now,
80
+ updated_at: Time.now
81
+ )
82
+
83
+ user = DB[:users].where(id: user_id).first
84
+ response.status = 201
85
+ { user: user }
86
+ rescue Sequel::UniqueConstraintViolation
87
+ response.status = 422
88
+ { error: "Email already exists" }
89
+ end
90
+ end
91
+ end
92
+
93
+ r.on Integer do |user_id|
94
+ r.is do
95
+ # GET /users/:id - Get a specific user
96
+ r.get do
97
+ user = DB[:users].where(id: user_id).first
98
+
99
+ unless user
100
+ response.status = 404
101
+ next { error: "User not found" }
102
+ end
103
+
104
+ { user: user }
105
+ end
106
+
107
+ # PUT /users/:id - Update a user
108
+ r.put do
109
+ user = DB[:users].where(id: user_id).first
110
+
111
+ unless user
112
+ response.status = 404
113
+ next { error: "User not found" }
114
+ end
115
+
116
+ data = JSON.parse(r.body.read)
117
+
118
+ begin
119
+ DB[:users].where(id: user_id).update(
120
+ name: data["name"] || user[:name],
121
+ email: data["email"] || user[:email],
122
+ updated_at: Time.now
123
+ )
124
+
125
+ updated_user = DB[:users].where(id: user_id).first
126
+ { user: updated_user }
127
+ rescue Sequel::UniqueConstraintViolation
128
+ response.status = 422
129
+ { error: "Email already exists" }
130
+ end
131
+ end
132
+
133
+ # DELETE /users/:id - Delete a user
134
+ r.delete do
135
+ user = DB[:users].where(id: user_id).first
136
+
137
+ unless user
138
+ response.status = 404
139
+ next { error: "User not found" }
140
+ end
141
+
142
+ DB[:users].where(id: user_id).delete
143
+ response.status = 204
144
+ ""
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ # Run the application with Puma
153
+ require "puma"
154
+
155
+ port = ENV.fetch("PORT", 8080).to_i
156
+
157
+ puts "Starting Roda + Cloudflare D1 API on port #{port}..."
158
+ puts "Database: #{ENV['DATABASE_ID']}"
159
+
160
+ Rack::Handler::Puma.run(
161
+ App.freeze.app,
162
+ Port: port,
163
+ Host: "0.0.0.0",
164
+ Threads: "1:5"
165
+ )
@@ -0,0 +1,17 @@
1
+ Sequel.migration do
2
+ up do
3
+ create_table(:users) do
4
+ primary_key :id
5
+ String :name, null: false
6
+ String :email, null: false
7
+ DateTime :created_at
8
+ DateTime :updated_at
9
+
10
+ index :email, unique: true
11
+ end
12
+ end
13
+
14
+ down do
15
+ drop_table(:users)
16
+ end
17
+ end