edgeway 0.1.0 → 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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +784 -125
  4. data/js/cloudflare-d1-sessions.js +68 -0
  5. data/js/cloudflare-kv.js +47 -0
  6. data/js/cloudflare-ratelimit.js +31 -0
  7. data/js/cloudflare-test-database.js +112 -0
  8. data/js/cloudflare-test-runner.js +76 -0
  9. data/js/cloudflare.js +144 -22
  10. data/js/cors.js +15 -16
  11. data/js/fetch.js +113 -0
  12. data/js/headers.js +16 -0
  13. data/js/jspi.js +32 -0
  14. data/js/jwt.js +6 -40
  15. data/js/runtime.js +42 -5
  16. data/lib/edgeway/application.rb +21 -9
  17. data/lib/edgeway/cache/limits.rb +8 -0
  18. data/lib/edgeway/cache/memory_store.rb +24 -0
  19. data/lib/edgeway/cache/null_store.rb +13 -0
  20. data/lib/edgeway/cache/store.rb +189 -0
  21. data/lib/edgeway/cache.rb +13 -0
  22. data/lib/edgeway/cli/build/app_config.rb +39 -0
  23. data/lib/edgeway/cli/build/app_files.rb +40 -0
  24. data/lib/edgeway/cli/build/cache_config.rb +108 -0
  25. data/lib/edgeway/cli/build/database_config.rb +96 -0
  26. data/lib/edgeway/cli/build/js_files.rb +30 -0
  27. data/lib/edgeway/cli/build/view_compiler.rb +191 -0
  28. data/lib/edgeway/cli/build.rb +50 -18
  29. data/lib/edgeway/cli/cloudflare_test/wasm_test_cli.rb +35 -0
  30. data/lib/edgeway/cli/cloudflare_test.rb +120 -0
  31. data/lib/edgeway/cli/generate.rb +174 -26
  32. data/lib/edgeway/cli/new.rb +1 -1
  33. data/lib/edgeway/cli/test.rb +28 -9
  34. data/lib/edgeway/cli/toolchain.rb +5 -0
  35. data/lib/edgeway/cli.rb +7 -4
  36. data/lib/edgeway/controller/base.rb +32 -3
  37. data/lib/edgeway/controller/caching.rb +140 -0
  38. data/lib/edgeway/controller/callbacks.rb +5 -1
  39. data/lib/edgeway/controller/dispatcher.rb +11 -1
  40. data/lib/edgeway/controller/rate_limiting.rb +145 -0
  41. data/lib/edgeway/controller/rendering.rb +24 -4
  42. data/lib/edgeway/db/adapter.rb +38 -0
  43. data/lib/edgeway/db/memory.rb +9 -2
  44. data/lib/edgeway/db/query_stats.rb +42 -0
  45. data/lib/edgeway/db/sqlite.rb +73 -9
  46. data/lib/edgeway/errors.rb +43 -0
  47. data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
  48. data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
  49. data/lib/edgeway/generators/app/templates/README.md.tt +8 -2
  50. data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
  51. data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
  52. data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
  53. data/lib/edgeway/generators/app/templates/bin/setup.tt +2 -3
  54. data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
  55. data/lib/edgeway/generators/app/templates/config/cache.yml +22 -0
  56. data/lib/edgeway/generators/app/templates/config/database.yml +25 -2
  57. data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
  58. data/lib/edgeway/generators/app/templates/public/.assetsignore +1 -0
  59. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +39 -0
  60. data/lib/edgeway/http/auth/stub.rb +1 -3
  61. data/lib/edgeway/http/client.rb +80 -0
  62. data/lib/edgeway/http/request.rb +7 -22
  63. data/lib/edgeway/http/stub_transport.rb +38 -0
  64. data/lib/edgeway/inflector.rb +19 -0
  65. data/lib/edgeway/logger.rb +33 -5
  66. data/lib/edgeway/model/attributes.rb +93 -10
  67. data/lib/edgeway/model/base.rb +50 -5
  68. data/lib/edgeway/model/connection_registry.rb +25 -0
  69. data/lib/edgeway/model/macros.rb +5 -1
  70. data/lib/edgeway/model/persistence.rb +18 -11
  71. data/lib/edgeway/model/relation.rb +106 -56
  72. data/lib/edgeway/model/validations.rb +29 -23
  73. data/lib/edgeway/rate_limit/limiter.rb +9 -0
  74. data/lib/edgeway/rate_limit/null_limiter.rb +9 -0
  75. data/lib/edgeway/rate_limit/stub_limiter.rb +24 -0
  76. data/lib/edgeway/rate_limit.rb +2 -0
  77. data/lib/edgeway/routing/route.rb +1 -6
  78. data/lib/edgeway/server.rb +13 -0
  79. data/lib/edgeway/service/cloudflare/d1.rb +44 -30
  80. data/lib/edgeway/service/cloudflare/jwt.rb +3 -15
  81. data/lib/edgeway/service/cloudflare/kv.rb +45 -0
  82. data/lib/edgeway/service/cloudflare/kv_store.rb +58 -0
  83. data/lib/edgeway/service/cloudflare/rate_limit.rb +27 -0
  84. data/lib/edgeway/service/cloudflare/rate_limiter.rb +33 -0
  85. data/lib/edgeway/service/cloudflare/test_databases.rb +65 -0
  86. data/lib/edgeway/service/fetch.rb +26 -0
  87. data/lib/edgeway/service/import_result.rb +24 -0
  88. data/lib/edgeway/test/sqlite_databases.rb +18 -0
  89. data/lib/edgeway/test.rb +86 -26
  90. data/lib/edgeway/version.rb +1 -1
  91. data/lib/edgeway/view.rb +24 -0
  92. data/lib/edgeway.rb +9 -0
  93. metadata +50 -4
  94. data/lib/edgeway/cli/app_config.rb +0 -35
  95. data/lib/edgeway/cli/app_files.rb +0 -37
data/README.md CHANGED
@@ -38,27 +38,221 @@ end
38
38
 
39
39
  ## Contents
40
40
 
41
+ - [Usage](#usage)
42
+ - [Generators](#generators)
43
+ - [Building](#building)
44
+ - [Spinel version](#spinel-version)
45
+ - [Configuration](#configuration)
41
46
  - [Models](#models)
47
+ - [Attribute types](#attribute-types)
42
48
  - [Strong parameters](#strong-parameters)
43
49
  - [Uniqueness](#uniqueness)
50
+ - [Database errors](#database-errors)
44
51
  - [Ordering and deleting](#ordering-and-deleting)
52
+ - [Queries](#queries)
53
+ - [Query stats](#query-stats)
45
54
  - [Batch](#batch)
46
55
  - [Associations](#associations)
47
56
  - [Eager loading](#eager-loading)
48
- - [Usage](#usage)
49
- - [Generators](#generators)
50
- - [Building](#building)
51
- - [Spinel version](#spinel-version)
52
- - [Known limitations](#known-limitations)
53
- - [Configuration](#configuration)
57
+ - [Multiple databases](#multiple-databases)
58
+ - [Read replication (Sessions)](#read-replication-sessions)
59
+ - [D1 API coverage](#d1-api-coverage)
60
+ - [Views](#views)
61
+ - [Static assets](#static-assets)
62
+ - [Logging](#logging)
54
63
  - [Authentication](#authentication)
55
64
  - [CORS](#cors)
65
+ - [HTTP caching](#http-caching)
66
+ - [Caching](#caching)
67
+ - [Stores](#stores)
68
+ - [config/cache.yml](#configcacheyml)
69
+ - [KV](#kv)
70
+ - [In tests](#in-tests)
71
+ - [Outbound HTTP](#outbound-http)
72
+ - [Rate limiting](#rate-limiting)
73
+ - [In tests](#in-tests-1)
56
74
  - [Testing an app](#testing-an-app)
75
+ - [Writing tests](#writing-tests)
76
+ - [Local D1 (`adapter: d1` in `test:`)](#local-d1-adapter-d1-in-test)
77
+ - [SQLite (`adapter: memory` in `test:`)](#sqlite-adapter-memory-in-test)
78
+ - [Differences from production](#differences-from-production)
79
+ - [Known limitations](#known-limitations)
57
80
  - [Development](#development)
58
81
  - [License](#license)
59
82
 
83
+ ## Usage
84
+
85
+ ```sh
86
+ gem install edgeway
87
+
88
+ edgeway new myapp
89
+
90
+ # mise, bundle, pnpm and the local D1 migrations
91
+ cd myapp && bin/setup
92
+
93
+ # the app's tests, as wasm on a local D1 (see "Testing an app")
94
+ bundle exec edgeway test # or bin/test [FILE...] in a generated app
95
+
96
+ # edgeway build: writes build/app.wasm and build/edgeway/ (the JS).
97
+ # A development build: Spinel's --incremental caches the generated C in units,
98
+ # so a rebuild recompiles only what changed (no inlining across units)
99
+ bin/build
100
+
101
+ pnpm exec wrangler dev
102
+ edgeway routes
103
+
104
+ # bin/build --release (one unit, without --incremental), remote D1 migrations,
105
+ # then pnpm exec wrangler deploy
106
+ bin/deploy
107
+ ```
108
+
109
+ `edgeway` commands: `new`, `build`, `test`, `generate` (`g`), `routes`, `version` (`edgeway help` lists them).
110
+
111
+ Before `bin/deploy`:
112
+
113
+ 1. Log in (`pnpm exec wrangler login`).
114
+ 2. Create the D1 database (`pnpm exec wrangler d1 create <name>-db`).
115
+ 3. Put its `database_id` in `wrangler.toml`.
116
+
117
+ `bin/setup` prints these steps when they are missing.
118
+
119
+ - A generated app's Gemfile takes `edgeway` and `spinel_test-unit` from RubyGems.
120
+ - A generated app's `.github/workflows/ci.yml` installs Ruby (from `.ruby-version`, which `edgeway new` writes with the Ruby that ran it), mise's tools and wrangler, then runs `bin/test` and `bin/build --release`.
121
+ - An app uses [pnpm](https://pnpm.io/) for wrangler (`package.json`, `bin/setup`, `mise.toml`);
122
+ `edgeway new` names it in one place (`Edgeway::CLI::PackageManager`).
123
+ - `SPINEL=/path/to/spinel bin/build` uses a Spinel other than the one on `PATH`.
124
+ - `bin/deploy` applies pending remote D1 migrations (`wrangler d1 migrations apply <name>-db --remote`) before deploying.
125
+
126
+ ### Generators
127
+
128
+ Inside an app made by `edgeway new`, `edgeway generate` (or `edgeway g`) writes the files and never overwrites one:
129
+
130
+ ```sh
131
+ # app/models/todo.rb, db/migrations/0001_create_todos.sql, test/models/todo_test.rb
132
+ edgeway generate model Todo title:string done:boolean
133
+
134
+ # app/controllers/todos_controller.rb, test/controllers/todos_test.rb
135
+ edgeway generate controller Todos index show
136
+
137
+ # the model's files, a JSON API controller (index, show, create, update, destroy) and its test
138
+ edgeway generate scaffold Todo title:string done:boolean
139
+ ```
140
+
141
+ - Field types are `string` (the default), `text`, `integer`, `float`, `boolean` and `datetime`.
142
+ - A model gets `timestamps` and an `id`, `created_at` and `updated_at` column.
143
+ - The migration is numbered one above the highest in `db/migrations`.
144
+ - Each action of a controller is a stub that renders `{}`.
145
+ - `config/routes.rb` is not changed by `model` and `controller`: the command prints the route to add (`r.resources :todos`).
146
+ - `model` and `scaffold` stop if a `db/migrations/*_create_todos.sql` already exists (a rerun would add a second table).
147
+ - `scaffold` singularizes its name (`scaffold Todos` is `Todo`), checks that none of its files exist before it writes one, and adds `r.resources :todos` as the last line of the `routes.draw` block, so that it does not shadow an earlier `/todos/search`. It leaves a route that is already there alone, and prints the route and the reason instead if there is no `routes.draw do |r|` block with a closing `end`.
148
+ - The scaffold's controller permits the given fields (`params.require(:todo).permit(...)`), and with no fields it takes no params.
149
+
150
+ ### Building
151
+
152
+ `edgeway build` needs Spinel and the WASI SDK.
153
+
154
+ - An app made by `edgeway new` has a `mise.toml` for both (`mise install`):
155
+ Spinel comes from the [mise-spinel](https://github.com/y-yagi/mise-spinel) plugin and is put on `PATH`,
156
+ and the WASI SDK is pointed at by `WASI_SDK`.
157
+ - Without mise, set `SPINEL` (or put `spinel` on `PATH`) and `WASI_SDK`.
158
+ - `SPINEL` wins over `spinel` on `PATH`, which is how to use a Spinel checkout instead of a release.
159
+
160
+ See `todo-api` in [edgeway-examples](https://github.com/y-yagi/edgeway-examples) for an example (checked with its `check.sh`).
161
+
162
+ ### Spinel version
163
+
164
+ Edgeway needs a Spinel newer than the 2026.09.12 release
165
+ (that release refuses `send` with a computed name, which `Callbacks#run_before_actions` uses).
166
+
167
+ - `mise.toml` (Edgeway's own and the one `edgeway new` writes) pins a commit of [matz/spinel](https://github.com/matz/spinel),
168
+ `ec0ad13e4`, which mise-spinel builds from source.
169
+ A commit rather than `master`, so that an install is the Spinel that was tested.
170
+ - The WASI SDK (`wasi-sdk-34`) is installed first,
171
+ because mise-spinel builds Spinel's wasm32-wasi runtime only when it finds one.
172
+ - Changing the pin means re-running `rake test` and `todo-api/check.sh` in [edgeway-examples](https://github.com/y-yagi/edgeway-examples).
173
+
174
+ ## Configuration
175
+
176
+ Two files configure the database, each for its own side:
177
+
178
+ - `config/database.yml` is Edgeway's: for each environment (`edgeway build --env ENV`),
179
+ the adapter (`d1`, or `memory`, which stores nothing) and the name of the D1 binding the app uses. It is compiled into the wasm.
180
+ - `wrangler.toml` is Cloudflare's: which D1 database the binding is (`database_name`, `database_id`),
181
+ the migrations, and the Worker. `binding` in it must match `binding` in `config/database.yml`.
182
+
183
+ Another database goes under `databases:` of an environment, with its own binding in `wrangler.toml`:
184
+
185
+ ```yaml
186
+ production:
187
+ adapter: d1
188
+ binding: DB
189
+ databases:
190
+ analytics:
191
+ binding: ANALYTICS
192
+ ```
193
+
194
+ Its migrations are in `db/analytics/migrations` (`migrations_dir` of its `[[d1_databases]]`; apply them per database).
195
+ A name is lowercase letters, digits and `_`, and not `primary`; `binding:` is required.
196
+ The extra databases use the environment's adapter.
197
+ `sessions: true` next to `binding:` (in the environment for the primary, in the entry for another) turns on D1 Sessions for it (see [Read replication](#read-replication-sessions)).
198
+
199
+ `database_id` is not a secret: it only names the database, and using it takes an API token of the account,
200
+ so it is committed with `wrangler.toml` as Cloudflare's own examples do.
201
+ Secrets the app needs go in Worker secrets (`pnpm exec wrangler secret put NAME`),
202
+ which the app reads with `Edgeway.config("NAME")`.
203
+
204
+ Not every Worker variable reaches the app. Only these are passed into the wasm:
205
+
206
+ - the framework's own settings (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_LEEWAY`, `JWT_ALGORITHMS`, `JWT_JWKS_URL`, `LOG_LEVEL`)
207
+ - the variables whose name starts with `APP_`
208
+
209
+ Name your own settings `APP_...` (`APP_PAGE_SIZE = "20"` in `[vars]`, `Edgeway.config("APP_PAGE_SIZE", "20")` in Ruby),
210
+ and likewise your secrets (`wrangler secret put APP_NAME`).
211
+ Anything else, such as a key the Worker holds for another purpose, is never visible to the app.
212
+ `JWT_SECRET` is never passed.
213
+
60
214
  ## Models
61
215
 
216
+ ### Attribute types
217
+
218
+ | Type | Ruby value | SQLite column | In JSON |
219
+ | --- | --- | --- | --- |
220
+ | `:string`, `:text` | String | TEXT | string |
221
+ | `:integer` | Integer (32 bits) | INTEGER | number |
222
+ | `:float` | Float | REAL | number |
223
+ | `:boolean` | `true`/`false` | INTEGER (1/0) | `true`/`false` |
224
+ | `:datetime` | String, ISO 8601 UTC | TEXT | string |
225
+ | `:binary` | binary String (`.b`) | BLOB | base64 string |
226
+ | `:bigint` | decimal String (`"3000000000"`) | INTEGER | string |
227
+
228
+ #### `:integer` and `:bigint`
229
+
230
+ - Integers are 32 bits in a wasm build. An `:integer` value outside -2147483648..2147483647 casts to `nil` and fails validation
231
+ ("is out of range"); use `:bigint` for such columns. A row that already holds one loads as `nil`, and saving it fails the same way
232
+ instead of writing `NULL` over it.
233
+ - A `:bigint` is a String of an optional `-` and digits, without leading zeros (`"-0"` is `"0"`); anything else, or a value beyond 64 bits,
234
+ casts to `nil` ("is invalid"). It is stored as an INTEGER, so `where`, `order` and comparisons work on the number,
235
+ and it renders in JSON as a String.
236
+ - D1 hands rows over as JavaScript numbers, so a value beyond 2^53 (9007199254740991) loses precision before Ruby sees it.
237
+ Keep values below 2^53, or store them as `:string`.
238
+ - Pass bigint values as Strings: `Todo.find("3000000000")`, `where(id: "3000000000")`, `create!(owner_id: "5000000000")`,
239
+ `Model.execute("... WHERE id = ?", ["3000000000"])`. An Integer literal beyond 32 bits in a Hash raises `RangeError` in a wasm build (a Spinel limitation).
240
+ - `attribute :id, :bigint` makes the primary key a bigint, and `id` is a String. A foreign key to it is `attribute :owner_id, :bigint`,
241
+ declared before `belongs_to` so it keeps its type.
242
+ - Raw SQL rows (`Model.select_all`) hold what the database gave: an INTEGER beyond 32 bits is a Bignum in a wasm build, and `render json:` of it raises.
243
+ Select it as text (`SELECT CAST(id AS TEXT) AS id ...`) or convert it with `Edgeway::Model.cast(:bigint, value)`.
244
+ - `Model.execute` answers `"last_insert_id"` as an Integer, or as a decimal String when the rowid is beyond 32 bits.
245
+
246
+ #### `:binary`
247
+
248
+ - A `:binary` attribute holds a binary String. It is bound as a BLOB (`where(body: bytes)` too) and renders in JSON as a base64 String.
249
+ - Assigned from Ruby, a String keeps its bytes (`Document.new(body: bytes)`), and so does a `{"$blob" => base64}` Hash.
250
+ - In a request body the value is the base64 String, so a GET then PATCH round trip is stable. Invalid base64 fails validation ("is invalid"),
251
+ and `{"$blob": ...}` objects are not supported (nested objects are dropped).
252
+ - `presence` counts a binary String blank only when empty, and `length` counts bytes.
253
+ - In raw SQL (`Model.select_all`, `Model.execute`, `where("...", value)`), bind a BLOB with `Edgeway::Model.blob(bytes)`;
254
+ a BLOB column comes back as `{"$blob" => base64}`, the form in which BLOBs cross between wasm and JS.
255
+
62
256
  ### Strong parameters
63
257
 
64
258
  Params must go through `permit` before a model takes them.
@@ -92,6 +286,22 @@ Project.where(owner_id: uid).find_or_create_by(name: "default")
92
286
  the database's message (table and columns) goes only to the log.
93
287
  - A controller can `rescue_from Edgeway::RecordNotUnique` to answer otherwise.
94
288
 
289
+ ### Database errors
290
+
291
+ All are `Edgeway::DatabaseError`; the database's message goes only to the log.
292
+
293
+ - `NotNullViolation`, `InvalidForeignKey` and `CheckViolation` (a constraint the app broke):
294
+ answered 500 `{"error":"internal error"}`, like any other `DatabaseError`.
295
+ - `DatabaseUnavailable` (D1 lost its connection, was reset or is overloaded):
296
+ answered 503 `{"error":"database unavailable"}` with `Retry-After: 1`.
297
+ - A controller can `rescue_from` any of them to answer otherwise.
298
+ `rescue_from Edgeway::DatabaseError` also catches `DatabaseUnavailable` and `RecordNotUnique`,
299
+ so it replaces their default 503 and 409.
300
+ - D1 retries read-only queries itself. Edgeway never retries a query, a write or a batch.
301
+ - A write that got a 503 may still have been applied:
302
+ only an idempotent request (a GET, or a write the app made idempotent) is safe to retry.
303
+ - Timeouts and CPU or memory limits stay a plain `DatabaseError` (500).
304
+
95
305
  ### Ordering and deleting
96
306
 
97
307
  ```ruby
@@ -115,6 +325,61 @@ class Todo < ApplicationModel
115
325
  end
116
326
  ```
117
327
 
328
+ ### Queries
329
+
330
+ ```ruby
331
+ Todo.where(id: [1, 2]) # id IN (?, ?)
332
+ Todo.where(position: 1..5) # position >= ? AND position <= ?
333
+ Todo.where(position: (3..)) # position >= ?
334
+ Todo.where("title LIKE ?", "%x%") # (title LIKE ?)
335
+ Todo.where("done = 1")
336
+
337
+ Todo.find_by_sql("SELECT * FROM todos WHERE done = ?", [true]) # records
338
+ Edgeway::Model.select_all("SELECT COUNT(*) AS n FROM todos", []) # row Hashes
339
+ Edgeway::Model.execute("DELETE FROM todos WHERE done = ?", [true]) # {"changes" => n, "last_insert_id" => id}
340
+ Edgeway::Model.exec("DELETE FROM tags\nDELETE FROM todos") # nil
341
+ ```
342
+
343
+ - `where(column: value)` is `=`, `IS NULL` for `nil`. Every form chains, and works with `order`, `limit`, `count`, `delete_all`, `eager_load` and `todo.comments.where(...)`.
344
+ - An Array is `IN`; each value is cast by the column type. An empty Array matches nothing (`1 = 0`).
345
+ A `nil` in an Array also matches `NULL`: `(position IN (?) OR position IS NULL)`.
346
+ - A Range is `>=` and `<=` (`<` for `1...5`); an endless or beginless Range has one side.
347
+ Spinel can't build a Range of Times: use ISO 8601 Strings (`"2026-10-28"..."2026-10-29"`) or a fragment.
348
+ - A String is a raw SQL fragment, in parentheses and ANDed with the rest; its binds follow it.
349
+ It is raw SQL: put user input in the binds, never interpolate it.
350
+ Its binds are not cast by column type: only booleans (1/0) and Times (ISO 8601) are converted.
351
+ An Array bind raises `ArgumentError` (not expanded): use `where(column: [...])`.
352
+ - `build`/`create`/`find_or_create_by` on a relation copy only the plain equality conditions.
353
+ - `find_by_sql` makes records of the rows, so select the model's columns (`SELECT *` or each of them): a missing one is `nil`.
354
+ - `Model.select_all` and `Model.execute` take `?` placeholders. Booleans are bound as 1/0, and a Time as ISO 8601 UTC (`2026-10-28T05:00:00.000Z`, as timestamps are stored), also in `where` values.
355
+ `execute` is a write, so inside `Model.batch` it is queued (and answers 0 counts).
356
+ - `Model.exec` is D1's `exec()`: one or more statements with no binds, and it answers `nil`. Meant for one-off maintenance, as D1 says.
357
+ A statement must fit on one line (D1 splits the text at newlines); `a; b` on one line also works. Empty SQL raises `ArgumentError`.
358
+ When a statement fails, none of them stay applied (seen on local D1; the SQLite adapter in tests does the same).
359
+ D1's error message names the first line, not the one that failed.
360
+ It can't run inside `Model.batch` (`ArgumentError`), and fails on a database with `sessions: true` (`DatabaseError`): a D1 session has no `exec()`.
361
+ - D1 allows 100 bound parameters per statement: more raises `ArgumentError`, in every adapter (`limit` and `offset` are literals, not binds).
362
+ `where(id: ids)` with over 100 ids needs chunking.
363
+
364
+ ### Query stats
365
+
366
+ `Edgeway::Model.query_stats` (it raises `ConfigurationError` without a connection) is what D1 reported for the queries of the current request
367
+ (a wasm instance serves one request, so it starts at zero):
368
+
369
+ - `queries`: the number of successful D1 calls (a `Model.batch` is one). A failed call and its time are not counted.
370
+ - `rows_read`, `rows_written`: the sums of D1's `meta`, capped at 2147483647 (32-bit).
371
+ - `duration_ms` (Float): the sum of D1's `meta.duration`.
372
+ - `served_by_region` and `served_by_primary`: from the last call (`""` and `true` when D1 doesn't say, as in `wrangler dev`).
373
+
374
+ When a request ran any query, `Edgeway::Server` logs one `query stats` line after it ([Logging](#logging)), also for a request that raised and was rescued.
375
+ Its fields are `queries`, `rows_read`, `rows_written`, `db_ms` and, for a database other than the primary, `database`:
376
+
377
+ ```json
378
+ {"level":"info","message":"query stats","queries":3,"rows_read":12,"rows_written":1,"db_ms":2.46}
379
+ ```
380
+
381
+ The Memory and SQLite adapters report zeros and log nothing.
382
+
118
383
  ### Batch
119
384
 
120
385
  `Edgeway::Model.batch` makes several writes atomic.
@@ -179,129 +444,171 @@ Todo.eager_load(:comments, :tags) # several at once, still one query
179
444
  - Only `to_a` and what is built on it (`each`, `map`, `size`) read the loaded records;
180
445
  `comments.where(...)`, `count` and `first` query as usual.
181
446
 
182
- ## Usage
183
-
184
- ```sh
185
- gem install edgeway
447
+ ### Multiple databases
186
448
 
187
- edgeway new myapp
188
-
189
- # mise, bundle, pnpm and the local D1 migrations
190
- cd myapp && bin/setup
449
+ A model uses the primary database unless it names another, declared in `config/database.yml` (see [Configuration](#configuration)):
191
450
 
192
- # the app's tests, natively (needs libsqlite3-dev, see "Testing an app")
193
- bundle exec edgeway test
194
-
195
- # edgeway build: writes build/app.wasm and build/edgeway/ (the JS)
196
- bin/build
197
-
198
- pnpm exec wrangler dev
199
- edgeway routes
451
+ ```ruby
452
+ class Event < ApplicationModel
453
+ self.database = :analytics # inherited by subclasses
454
+ attribute :name, :string
455
+ end
200
456
 
201
- # bin/build, remote D1 migrations, then pnpm exec wrangler deploy
202
- bin/deploy
457
+ Event.create!(name: "signup")
458
+ Edgeway::Model.select_all("SELECT COUNT(*) AS n FROM events", [], database: :analytics)
459
+ Edgeway::Model.execute("DELETE FROM events", [], database: :analytics)
203
460
  ```
204
461
 
205
- `edgeway` commands: `new`, `build`, `test`, `generate` (`g`), `routes`, `version` (`edgeway help` lists them).
206
-
207
- Before `bin/deploy`:
208
-
209
- 1. Log in (`pnpm exec wrangler login`).
210
- 2. Create the D1 database (`pnpm exec wrangler d1 create <name>-db`).
211
- 3. Put its `database_id` in `wrangler.toml`.
212
-
213
- `bin/setup` prints these steps when they are missing.
214
-
215
- - A generated app's Gemfile takes `edgeway` and `spinel_test-unit` from RubyGems.
216
- - An app uses [pnpm](https://pnpm.io/) for wrangler (`package.json`, `bin/setup`, `mise.toml`);
217
- `edgeway new` names it in one place (`Edgeway::CLI::PackageManager`).
218
- - `SPINEL=/path/to/spinel bin/build` uses a Spinel other than the one on `PATH`.
219
- - `bin/deploy` applies pending remote D1 migrations (`wrangler d1 migrations apply <name>-db --remote`) before deploying.
220
-
221
- ### Generators
222
-
223
- Inside an app made by `edgeway new`, `edgeway generate` (or `edgeway g`) writes the files and never overwrites one:
224
-
225
- ```sh
226
- # app/models/todo.rb, db/migrations/0001_create_todos.sql, test/models/todo_test.rb
227
- edgeway generate model Todo title:string done:boolean
228
-
229
- # app/controllers/todos_controller.rb, test/controllers/todos_test.rb
230
- edgeway generate controller Todos index show
462
+ - Every query and write of a model goes to its database, including `find_by_sql`.
463
+ `Model.select_all` and `Model.execute` use the primary unless given `database:`.
464
+ - Associations between models on different databases work as separate queries.
465
+ A `has_many` with `dependent:` to a model on another database raises `ArgumentError` on `destroy`, before any query: it can't be atomic.
466
+ `eager_load` across databases raises `ArgumentError`: a `JOIN` can't cross them.
467
+ - A `Model.batch` belongs to the database of its first write; a write for another one raises `ArgumentError`
468
+ (D1 batches are per database). Use one `batch` per database.
469
+ - `Edgeway::Model.query_stats(:analytics)` is that database's stats. The request logs one `query stats` line per database that ran queries,
470
+ with a `database` field for the ones other than the primary.
471
+
472
+ ### Read replication (Sessions)
473
+
474
+ With D1 read replication enabled for a database (dashboard, or `PUT` its settings), reads can be served by a replica near the user.
475
+ A replica may lag, so D1's Sessions API keeps a request sequentially consistent through a bookmark. Opt in per database in `config/database.yml`:
476
+
477
+ ```yaml
478
+ production:
479
+ adapter: d1
480
+ binding: DB
481
+ sessions: true
482
+ databases:
483
+ analytics:
484
+ binding: ANALYTICS
485
+ sessions: true
231
486
  ```
232
487
 
233
- - Field types are `string` (the default), `text`, `integer`, `float`, `boolean` and `datetime`.
234
- - A model gets `timestamps` and an `id`, `created_at` and `updated_at` column.
235
- - The migration is numbered one above the highest in `db/migrations`.
236
- - Each action of a controller is a stub that renders `{}`.
237
- - `config/routes.rb` is not changed: the command prints the route to add (`r.resources :todos`).
238
-
239
- ### Building
240
-
241
- `edgeway build` needs Spinel and the WASI SDK.
242
-
243
- - An app made by `edgeway new` has a `mise.toml` for both (`mise install`):
244
- Spinel comes from the [mise-spinel](https://github.com/y-yagi/mise-spinel) plugin and is put on `PATH`,
245
- and the WASI SDK is pointed at by `WASI_SDK`.
246
- - Without mise, set `SPINEL` (or put `spinel` on `PATH`) and `WASI_SDK`.
247
- - `SPINEL` wins over `spinel` on `PATH`, which is how to use a Spinel checkout instead of a release.
248
-
249
- See `todo-api` in [edgeway-examples](https://github.com/y-yagi/edgeway-examples) for an example (checked with its `check.sh`).
488
+ `sessions:` must be `true` or `false` (anything else fails the build), and does nothing with the `memory` adapter.
489
+ Every query and `Model.batch` of such a database then goes through `env.DB.withSession(...)`, created on first use and kept for the request.
250
490
 
251
- ### Spinel version
252
-
253
- Edgeway needs a Spinel newer than the 2026.09.12 release
254
- (that release refuses `send` with a computed name, which `Callbacks#run_before_actions` uses).
491
+ - A `GET` or `HEAD` starts from the bookmark the client sent, else `first-unconstrained`, so it may read a replica. Other methods always start `first-primary` and ignore the sent bookmark.
492
+ - The response has `X-D1-Bookmark: DB=<bookmark>, ANALYTICS=<bookmark>`: a `BINDING=bookmark` pair for each session the request used (also with one database).
493
+ - A client that sends that header back as it is on its next request starts that session from the bookmark and so reads its own writes (`GET` and `HEAD` only). On a `GET` or `HEAD`, a binding that is not in the header starts `first-unconstrained`. A bookmark is D1's and opaque; one that does not look like it (not lowercase hex and `-`, or over 128 characters) is ignored, and not echoed.
494
+ - A database the request did not use keeps the bookmark the client sent for it in the response (on any method), so the client just stores the latest header value whole and sends it back.
495
+ - Responses of an app with sessions get `Vary: X-D1-Bookmark`.
496
+ - With CORS on, `X-D1-Bookmark` is exposed to the page and allowed in preflights (no need to add it to `CORS_HEADERS` or `CORS_EXPOSE_HEADERS`).
497
+ - `Edgeway::Model.query_stats(:analytics)` has `served_by_primary` and `served_by_region` of the last query, to see which one answered (`true` and `""` in `wrangler dev`, which has no replicas).
255
498
 
256
- - `mise.toml` (Edgeway's own and the one `edgeway new` writes) pins a commit of [matz/spinel](https://github.com/matz/spinel),
257
- `530ba2b17`, which mise-spinel builds from source.
258
- A commit rather than `master`, so that an install is the Spinel that was tested.
259
- - The WASI SDK (`wasi-sdk-34`) is installed first,
260
- because mise-spinel builds Spinel's wasm32-wasi runtime only when it finds one.
261
- - Changing the pin means re-running `rake test` and `todo-api/check.sh` in [edgeway-examples](https://github.com/y-yagi/edgeway-examples).
499
+ ### D1 API coverage
262
500
 
263
- ### Known limitations
501
+ | D1 Worker API | In Edgeway |
502
+ | --- | --- |
503
+ | `prepare().bind().all()` | every query, `find_by_sql`, `Model.select_all`, and inserts (`INSERT ... RETURNING id`) |
504
+ | `prepare().bind().run()` | updates and deletes, `Model.execute` |
505
+ | `batch()` | `Model.batch` (and `destroy` with dependents) |
506
+ | `exec()` | `Model.exec` |
507
+ | `withSession()`, `getBookmark()` | `sessions: true` ([Read replication](#read-replication-sessions)) |
508
+ | `result.meta` | `Model.query_stats` and the `query stats` log line |
509
+ | `first()`, `raw()` | not used (`find_by`/`first` send `LIMIT 1` through `all()`) |
510
+ | `dump()` | not supported (alpha databases only) |
264
511
 
265
- - Only the subset of Ruby that Spinel supports can be used.
266
- - `has_many` does not work with namespaced models (`Admin::Post`);
267
- `belongs_to` and `has_many` look models up by their full name, and that case is not handled.
268
- - Cloudflare Access is not supported in this release: its token comes in `Cf-Access-Jwt-Assertion`,
269
- and only `Authorization: Bearer` is read (see [Authentication](#authentication)).
270
- - Tests run natively, not in wasm: D1 and JSPI (`Edgeway::Cloudflare::JWTVerifier`) are not covered by them,
271
- and the production wasm never loads the SQLite adapter (see [Testing an app](#testing-an-app)).
272
- - Not verified on a production Cloudflare deploy yet: CPU and wall time on a cold start and on a JWKS cache miss.
273
- Everything was checked under `wrangler dev`.
512
+ Time Travel, export/import and migrations are `wrangler d1` commands, outside the Worker API.
274
513
 
275
- ## Configuration
514
+ ## Views
276
515
 
277
- Two files configure the database, each for its own side:
516
+ ```ruby
517
+ # app/controllers/todos_controller.rb
518
+ class TodosController < ApplicationController
519
+ def index
520
+ @todos = Todo.order(:id).limit(50)
521
+ # renders app/views/todos/index.html.erb
522
+ end
523
+ end
524
+ ```
278
525
 
279
- - `config/database.yml` is Edgeway's: for each environment (`edgeway build --env ENV`),
280
- the adapter (`d1`, or `memory` for tests) and the name of the D1 binding the app uses. It is compiled into the wasm.
281
- - `wrangler.toml` is Cloudflare's: which D1 database the binding is (`database_name`, `database_id`),
282
- the migrations, and the Worker. `binding` in it must match `binding` in `config/database.yml`.
526
+ ```erb
527
+ <%# app/views/todos/index.html.erb %>
528
+ <ul>
529
+ <% @todos.each do |t| -%>
530
+ <li><%= t.title %></li>
531
+ <% end -%>
532
+ </ul>
533
+ ```
283
534
 
284
- `database_id` is not a secret: it only names the database, and using it takes an API token of the account,
285
- so it is committed with `wrangler.toml` as Cloudflare's own examples do.
286
- Secrets the app needs go in Worker secrets (`pnpm exec wrangler secret put NAME`),
287
- which the app reads with `Edgeway.config("NAME")`.
535
+ Spinel has no `eval`, so `edgeway build` (and `edgeway test`) compile `app/views/**/*.html.erb` into Ruby
536
+ (`build/views.rb`, `tmp/views.rb`): each template becomes a method of its controller,
537
+ so it sees the action's `@variables`, `params` and the controller's methods.
538
+
539
+ - `<%= expr %>` is HTML-escaped (`Edgeway::View.escape`) unless the value is `raw(text)` or `text.html_safe`
540
+ (an `Edgeway::View::SafeHTML`); `<%== expr %>` is never escaped.
541
+ Safety is not kept across string operations (`"<b>".html_safe + x` is not defined): build the HTML in a template,
542
+ or escape the pieces with `Edgeway::View.escape` and mark the result.
543
+ `<% code %>`, `<%# comment %>`, `<%%` (a literal `<%`) and `-%>` (drops the newline after the tag) work as in ERB.
544
+ - The public methods of `Edgeway::Controller::Base` (`render`, `head`, `params`, `raw`, ...) are not actions; a route to one is a 404.
545
+ - An action that renders nothing renders its template, or answers 204 when it has none.
546
+ - `render :name` / `render "name"` renders another template of the controller, `render html: value` a string (HTML-escaped; a `SafeHTML` is output as is);
547
+ both take `status:` and `layout: false`.
548
+ A name with no template raises `Edgeway::MissingTemplate` (a 500).
549
+ - `app/views/layouts/application.html.erb` wraps every HTML response, at `<%= yield %>`. `edgeway new` writes one.
550
+ - HTML responses are `Content-Type: text/html; charset=utf-8` with `X-Content-Type-Options: nosniff`.
551
+ - A template's directory names its controller (`app/views/admin/posts/` is `Admin::PostsController`);
552
+ one without a controller is a build error, and so is a syntax error in a template,
553
+ which Spinel reports in the generated file (its `# app/views/...:LINE` comments point back).
554
+
555
+ Not supported yet: partials (`render partial:`), layouts other than `application`, and templates other than `.html.erb`.
556
+
557
+ ## Static assets
558
+
559
+ `public/` is served by Cloudflare as Workers Static Assets (`[assets]` in `wrangler.toml`).
560
+ A request that matches a file is answered without starting the wasm (no CPU time for the app); the others go to the app.
561
+
562
+ - The files are public: authentication in the app does not apply to them.
563
+ - A file takes its path from the app, for every method. With the default `html_handling` (`auto-trailing-slash`),
564
+ `public/todos.html` answers `GET /todos`, and `POST /todos` is an empty 405; `public/index.html` answers `/`.
565
+ - The Worker does not run for them, so CORS and the app's headers do not apply (Cloudflare reads headers for them from `public/_headers`).
566
+ - `public/.assetsignore` (`.gitignore` syntax) lists files not to upload. `edgeway new` writes it, which also keeps `public/` in git
567
+ (wrangler fails without the directory).
568
+ - `edgeway test` calls the app directly, so tests do not see `public/`.
569
+
570
+ For a single-page app, `not_found_handling = "single-page-application"` serves `index.html` for unknown paths.
571
+ With a `compatibility_date` of 2025-04-01 or later it also answers browser navigations (`Sec-Fetch-Mode: navigate`)
572
+ with assets before the Worker runs, so the app's HTML views are hidden and a browser that opens an API URL gets `index.html`.
573
+ List the app's paths in `run_worker_first` (patterns support `*` and `!` negation; needs wrangler 4.20.0 or later).
574
+ It replaces that navigation detection, so every app path must be listed: one that is not gets `index.html`, even from `fetch`.
575
+
576
+ ```toml
577
+ [assets]
578
+ directory = "./public"
579
+ not_found_handling = "single-page-application"
580
+ run_worker_first = ["/health", "/todos", "/todos/*"]
581
+ ```
288
582
 
289
- Not every Worker variable reaches the app. Only these are passed into the wasm:
583
+ ## Logging
290
584
 
291
- - the framework's own settings (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_LEEWAY`, `JWT_ALGORITHMS`, `JWT_JWKS_URL`)
292
- - the variables whose name starts with `APP_`
585
+ ```ruby
586
+ Edgeway.logger.info("order placed", order_id: 42, total: 12.5)
587
+ Edgeway.logger.error("payment failed", order_id: 42)
588
+ ```
293
589
 
294
- Name your own settings `APP_...` (`APP_PAGE_SIZE = "20"` in `[vars]`, `Edgeway.config("APP_PAGE_SIZE", "20")` in Ruby),
295
- and likewise your secrets (`wrangler secret put APP_NAME`).
296
- Anything else, such as a key the Worker holds for another purpose, is never visible to the app.
297
- `JWT_SECRET` is never passed.
590
+ - `debug`, `info`, `warn` and `error` take a message and optional fields (a Hash, or `key: value`).
591
+ - Each call writes one line to stderr, a JSON object: `{"level":"info","message":"order placed","order_id":42,"total":12.5}`.
592
+ Field names are Strings or Symbols; values are Strings, numbers, `true`/`false`, `nil`, or Arrays/Hashes of them.
593
+ `level` and `message` can't be fields.
594
+ - `LOG_LEVEL` (`debug`, `info`, `warn`, `error`; default `info`) is the lowest level written. An unknown value counts as `info`.
595
+ Set it in `[vars]` of `wrangler.toml`.
596
+ - The runtime sends each line to `console.<level>` as an object, so every line is one Workers Logs entry at its level, with its fields indexed.
597
+ It adds `ray` (the `cf-ray` header, when there is one). A field of the same name that the app logs wins over it.
598
+ - Other stderr text (Spinel's runtime messages, a trap) goes out as text at error level.
599
+ - Turn Workers Logs on with `[observability]` `enabled = true` in `wrangler.toml` (`edgeway new` writes it).
600
+ It records at most 256 KB of log data per request; nothing beyond that is kept.
601
+ - `Edgeway::Application` logs the rescued errors (401, 404, 409, 429 at `info`, 503 and 500 at `error`) with a `status` field.
602
+ - `edgeway test` with a local D1 prints a structured line as text, `[edgeway] INFO order placed order_id=42 total=12.5`.
603
+ A native build (`adapter: memory`) writes the JSON lines as they are.
604
+ - `Edgeway.logger =` replaces the logger (an object with the four methods, each `(message, fields = {})`), as the framework's tests do.
298
605
 
299
606
  ## Authentication
300
607
 
301
608
  Edgeway verifies JWTs that an external IdP issued (Auth0, Firebase, Cognito, ...) and that come in `Authorization: Bearer`;
302
609
  it does not issue them.
303
610
 
304
- - Cloudflare Access, which sends its token in `Cf-Access-Jwt-Assertion`, is not supported in the first release,
611
+ - Cloudflare Access, which sends its token in `Cf-Access-Jwt-Assertion`, is not supported,
305
612
  nor are Cognito access tokens, which carry `client_id` instead of `aud` (Cognito ID tokens work).
306
613
  - `sub` is required and must be a non-empty string.
307
614
  - `auth_time`, when present, must not be in the future (leeway applies).
@@ -324,19 +631,29 @@ Without a valid `Authorization: Bearer <token>`, the request is answered 401 wit
324
631
  - a bad token: `{"error":"invalid token"}` with `WWW-Authenticate: Bearer error="invalid_token"` (`Edgeway::InvalidToken`)
325
632
 
326
633
  `rescue_from` them to answer differently.
327
- The reason ("token expired", "unknown kid", ...) is only in the log (stderr), never the token,
634
+ The reason ("token expired", "unknown kid", ...) is only in the log, never the token,
328
635
  so a client learns nothing from the wording.
329
636
 
330
637
  Settings are Worker variables in `wrangler.toml`, and the secret is a Worker secret (`.dev.vars` locally):
331
638
 
332
639
  | Variable | |
333
640
  |---|---|
641
+ | `JWT_ISSUER`, `JWT_AUDIENCE` | **required**: the token's `iss` and `aud` must match |
334
642
  | `JWT_ALGORITHMS` | allowed algorithms, comma-separated (default `RS256`; `HS256/384/512`, `RS*`, `PS*`, `ES256/384`, `EdDSA`). `none` is never accepted |
335
- | `JWT_JWKS_URL` | the IdP's JWKS, for the asymmetric algorithms. Must be `https` (`http` only for `localhost`, `127.0.0.1`, `[::1]`), otherwise the request is answered 500. Cached for 10 minutes, fetched again when a `kid` is unknown (at most every 30 seconds); an expired JWKS is never used, so when the fetch fails the answer is 500. The fetch has a 5 second timeout, follows no redirect, needs status 200 and a body of at most 64 KB. A token needs a `kid`; a JWK with `key_ops` must list `verify` |
336
- | `JWT_SECRET` | the shared secret, for `HS*` (a secret; it never enters the wasm). At least 32 bytes for `HS256`, 48 for `HS384`, 64 for `HS512` (UTF-8 bytes, RFC 7518 3.2), otherwise the request is answered 500 |
337
- | `JWT_ISSUER`, `JWT_AUDIENCE` | **required**: `iss` and `aud` of the token must match. If either is empty the request is answered 500 (a misconfiguration, details only in the log), never let through; a token without `iss` or `aud` is 401. Without them a token the IdP issued for another API would pass: Firebase's signing keys are shared by all Firebase projects (so any project's user token), and an Auth0 Management API token of the same tenant. Applies to every algorithm, `HS*` too |
338
- | `JWT_LEEWAY` | seconds of clock skew allowed for `exp`, `nbf`, `iat` and `auth_time` (default 5; `0` for none; a whole number up to 300, anything else is answered 500) |
339
-
643
+ | `JWT_JWKS_URL` | the IdP's JWKS, for the asymmetric algorithms |
644
+ | `JWT_SECRET` | the shared secret, for `HS*` (a Worker secret; it never enters the wasm) |
645
+ | `JWT_LEEWAY` | seconds of clock skew allowed for `exp`, `nbf`, `iat` and `auth_time` (default 5, up to 300) |
646
+
647
+ A misconfigured setting answers every request 500, with the details only in the log; a request is never let through.
648
+
649
+ - `JWT_ISSUER` and `JWT_AUDIENCE` apply to every algorithm, `HS*` too. Without them a token the IdP issued for another API would pass:
650
+ Firebase's signing keys are shared by all Firebase projects, and Auth0 signs its Management API tokens with the tenant's keys.
651
+ A token without `iss` or `aud` is 401.
652
+ - `JWT_JWKS_URL` must be `https` (`http` only for `localhost`, `127.0.0.1`, `[::1]`).
653
+ The JWKS is cached for 10 minutes and fetched again when a `kid` is unknown (at most every 30 seconds); an expired one is never used.
654
+ The fetch has a 5 second timeout, follows no redirect, and needs status 200 and a body of at most 64 KB.
655
+ A token needs a `kid`, and a JWK with `key_ops` must list `verify`.
656
+ - `JWT_SECRET` must be at least 32 bytes for `HS256`, 48 for `HS384` and 64 for `HS512` (RFC 7518 3.2).
340
657
  - `exp` is required, and a token with a `crit` header is refused (no extension is understood, RFC 7515 4.1.11).
341
658
  - In tests, use `Edgeway::Auth::StubVerifier` (`edgeway/http/auth/stub`; see `test/auth.rb`).
342
659
  - Like D1, this needs JSPI.
@@ -362,27 +679,303 @@ Settings are Worker variables in `wrangler.toml`:
362
679
 
363
680
  | Variable | |
364
681
  |---|---|
365
- | `CORS_ORIGINS` | allowed origins, comma-separated, compared as exact strings with the request's `Origin` (`https://app.example, http://localhost:3000`; no trailing slash). `*` allows any origin. Unset or empty means CORS is off: nothing is added and `OPTIONS` goes to the app. The origin `null` (sandboxed iframes, `file:` pages) is never allowed, even with `*` |
366
- | `CORS_HEADERS` | request headers a preflight allows (default `Authorization, Content-Type`). Listed one by one, as `*` does not cover `Authorization` |
367
- | `CORS_MAX_AGE` | seconds a browser may cache a preflight (default `600`). Not a non-negative integer: every request is answered 500, as with a bad JWT setting |
368
- | `CORS_EXPOSE_HEADERS` | response headers the page may read (`Access-Control-Expose-Headers`, e.g. `X-Total-Count`); none by default |
369
-
682
+ | `CORS_ORIGINS` | allowed origins, comma-separated (`https://app.example, http://localhost:3000`), or `*` for any. Unset or empty turns CORS off |
683
+ | `CORS_HEADERS` | request headers a preflight allows (default `Authorization, Content-Type`) |
684
+ | `CORS_MAX_AGE` | seconds a browser may cache a preflight (default `600`) |
685
+ | `CORS_EXPOSE_HEADERS` | response headers the page may read (e.g. `X-Total-Count`) |
686
+
687
+ - Origins are compared as exact strings with the request's `Origin` (no trailing slash). The origin `null` (sandboxed iframes, `file:` pages)
688
+ is never allowed, even with `*`. With CORS off, nothing is added and `OPTIONS` goes to the app.
689
+ - List `CORS_HEADERS` one by one: `*` does not cover `Authorization`.
690
+ - `Retry-After` is always exposed. With `sessions: true`, `X-D1-Bookmark` is added to the allowed and exposed headers.
691
+ - A `CORS_MAX_AGE` that is not a non-negative integer answers every request 500, as a bad JWT setting does.
370
692
  - A preflight from an allowed origin is answered 204 with `Access-Control-Allow-Origin` (the request's origin),
371
693
  `Access-Control-Allow-Methods` (`GET, HEAD, POST, PUT, PATCH, DELETE`), `Access-Control-Allow-Headers` and `Access-Control-Max-Age`.
372
694
  - A preflight from another origin is 403 `{"error":"origin not allowed"}`.
373
695
  - Other responses get `Access-Control-Allow-Origin` when the `Origin` is allowed, and `Origin` is added to `Vary`.
696
+ - Files in `public/` are answered before the Worker, so they get no CORS headers and a preflight to one is 405 (see [Static assets](#static-assets)).
374
697
  - Cookies (credentials) are not supported: `Access-Control-Allow-Credentials` is never sent,
375
698
  and a token that `fetch` puts in `Authorization` does not need it.
376
699
 
377
- Serving the frontend from the same origin as the API avoids CORS altogether.
700
+ Serving the frontend from the same origin as the API (see [Static assets](#static-assets)) avoids CORS altogether.
701
+
702
+ ## HTTP caching
703
+
704
+ ```ruby
705
+ class TodosController < ApplicationController
706
+ def show
707
+ todo = Todo.find(params["id"])
708
+ render json: todo if stale?(etag: todo, last_modified: todo.updated_at)
709
+ end
710
+
711
+ def index
712
+ todos = Todo.order(:id)
713
+ render json: todos if stale?(etag: todos)
714
+ end
715
+
716
+ def settings
717
+ expires_in 300, public: true
718
+ render json: { "theme" => "dark" }
719
+ end
720
+ end
721
+ ```
722
+
723
+ - `stale?(etag:, last_modified:, public: false)` sets the validators and answers `304 Not Modified` (empty body; `Content-Type` is dropped)
724
+ when the request's `If-None-Match` / `If-Modified-Since` match; it returns `false` then, so the action stops. `fresh_when` is the same
725
+ and returns `true` when it answered 304.
726
+ - `etag:` takes a model (its class, `id` and `updated_at`; a model without `updated_at` raises `ArgumentError`), a `Relation` or an Array of them (each element counts, in order),
727
+ or anything else (its `to_s`). The header is a weak `ETag: W/"<32 hex digits>"` (a proxy may recompress the body and weaken a strong one).
728
+ - The ETag depends only on what you pass, not on the JSON an action renders. When a deploy changes what an action renders for the same record,
729
+ add a version to the source (`stale?(etag: [todo, "v2"], ...)`), or clients keep the old shape through 304 until the record changes.
730
+ - Cross-origin JavaScript that handles ETags itself needs `If-None-Match` in `CORS_HEADERS` and `ETag` in `CORS_EXPOSE_HEADERS`
731
+ (see [CORS](#cors)); the browser's own cache revalidation needs neither.
732
+ - `last_modified:` takes a `Time` or an ISO 8601 String (a model's `updated_at`); anything else is an `ArgumentError`.
733
+ - Freshness: when `If-None-Match` is present it alone decides (weak comparison; a list, or `*` when the action has an ETag) and
734
+ `If-Modified-Since` is ignored; otherwise the response is fresh when `Last-Modified` is not later than `If-Modified-Since`
735
+ (HTTP-date only; one that does not parse is stale). Only GET and HEAD are answered 304; other methods get the headers only.
736
+ - Only the validators you give are used; Edgeway does not derive an ETag from the body.
737
+ - Unless the action set `Cache-Control` already (`expires_in`, `no_store` or `headers["Cache-Control"]`), `fresh_when` sets
738
+ `max-age=0, private, must-revalidate` (`public` with `public: true`). Without a `Cache-Control`, a shared cache may keep the response by heuristic.
739
+ - `expires_in(seconds, public: false, must_revalidate: false)` sets `Cache-Control: max-age=N, private` (or `public`, and `, must-revalidate`);
740
+ `seconds` must be a non-negative Integer. `no_store` sets `Cache-Control: no-store`.
741
+
742
+ Cloudflare's Workers Cache (opt-in: `[cache] enabled = true` in `wrangler.toml`, wrangler 4.69.0 or later) sits in front of the Worker.
743
+ Edgeway does not enable it. If you do:
744
+
745
+ - A `public` response with `max-age` is served from the cache without running the app, so `before_action :authenticate!` does not run for it.
746
+ A request with an `Authorization` header bypasses the cache unless the response is `public`, `must-revalidate` or has `s-maxage`.
747
+ - `private` and `no-store` responses bypass it.
748
+ - A response with no `Cache-Control` is cached by heuristic (a 200 for 2 hours).
749
+
750
+ ## Caching
751
+
752
+ `Edgeway.cache` is a cache store like Rails' `Rails.cache`:
753
+
754
+ ```ruby
755
+ class TodosController < ApplicationController
756
+ def stats
757
+ stats = Edgeway.cache.fetch("todo-stats", expires_in: 300) do
758
+ { "open" => Todo.where(done: false).count, "done" => Todo.where(done: true).count }
759
+ end
760
+ render json: stats
761
+ end
762
+
763
+ def show
764
+ todo = Todo.find(params["id"])
765
+ render json: Edgeway.cache.fetch(todo) { todo.as_json }
766
+ end
767
+ end
768
+ ```
769
+
770
+ - `read(key)` answers the value, or `nil` for a miss. `write(key, value, expires_in: nil)` and `delete(key)` answer `true`, or `false` when the store failed.
771
+ `exist?(key)` is true for an entry, also one that holds `nil`.
772
+ - `fetch(key, expires_in: nil, force: false, skip_nil: false) { ... }` answers the cached value, or runs the block, stores its value and answers it.
773
+ `force: true` always runs the block. A `nil` is cached like any value; with `skip_nil: true` a `nil` from the block is not stored.
774
+ Without a block, `fetch` is `read` (with `force: true` it raises `ArgumentError`). A `delete` answers `true` also when there was nothing to delete.
775
+ - Values are JSON values: a String (valid UTF-8), Integer, Float (finite), `true`, `false`, `nil`, and Arrays and Hashes of them.
776
+ Anything else (a Time, a model, a String that is not valid UTF-8, which includes a binary String such as `"\xFF".b`) raises `ArgumentError`; put `todo.as_json` in the cache, not the model.
777
+ A Hash comes back with String keys.
778
+ - Keys: a String, Symbol or Integer is used as it is (any other type raises `ArgumentError`, and so does a key that is not valid UTF-8 or has a NUL byte); an Array is joined with `/`; a model is `"<table>/<id>-<updated_at>"`, so updating
779
+ the record makes a new key instead of needing a delete (a model without `updated_at` raises `ArgumentError`).
780
+ A key must not be blank. A key over 512 bytes is cut and ends with the SHA256 of the whole key, so two long keys that share a start stay apart.
781
+ - `expires_in:` is a whole number of seconds, 1 to 31,536,000 (a year); anything else raises `ArgumentError`. The store checks it on every read, to the second.
782
+ Without `expires_in:` (and without a default from `config/cache.yml`) an entry stays until it is deleted, or the backend drops it.
783
+ - A store failure never fails the request: it is logged at `warn` (with the binding and the operation, not the key), a read is a miss,
784
+ and `write` and `delete` answer `false`. An explicit `expires_in: nil` is the same as leaving it out: the store's default from `config/cache.yml` applies (unlike Rails, where it means no expiry).
785
+ - `Edgeway.cache = store` replaces the store.
786
+
787
+ ### Stores
788
+
789
+ | `store:` | Class | Entries |
790
+ | --- | --- | --- |
791
+ | `null` (the default) | `Edgeway::Cache::NullStore` | not kept: `fetch` always runs its block |
792
+ | `memory` | `Edgeway::Cache::MemoryStore` | in the process: in a Worker, for one request |
793
+ | `kv` | `Edgeway::Cloudflare::KVStore` | in a Cloudflare KV namespace |
794
+
795
+ ### config/cache.yml
796
+
797
+ For each environment (`edgeway build --env ENV`):
798
+
799
+ ```yaml
800
+ production:
801
+ store: kv
802
+ binding: CACHE # a [[kv_namespaces]] binding in wrangler.toml; required for kv
803
+ namespace: myapp # optional: every key starts with "myapp:"
804
+ expires_in: 3600 # optional: the seconds for a call that gives no expires_in:
805
+ ```
806
+
807
+ Without the file, or a section for the environment, the store is `null`. `store: null` (also written without quotes) is the same.
808
+ `edgeway build` writes the matching `Edgeway.cache = ...` line into `build/main.rb`, and links the KV backend only for `store: kv`.
809
+
810
+ The binding is declared in `wrangler.toml` (`edgeway new` writes this commented out):
811
+
812
+ ```toml
813
+ [[kv_namespaces]]
814
+ binding = "CACHE"
815
+ id = "<namespace id>"
816
+ ```
817
+
818
+ Create the namespace with `pnpm exec wrangler kv namespace create CACHE`. `wrangler dev` simulates it locally.
819
+ A `kv` store that names a binding the Worker does not have fails every operation (logged, read as a miss).
820
+
821
+ ### KV
822
+
823
+ - KV drops an entry when its `expirationTtl` passes, and its minimum is 60 seconds. For an `expires_in:` under 60 the store asks KV for 60 and
824
+ checks the exact time itself on read, so `expires_in: 10` still means 10 seconds.
825
+ - KV is eventually consistent: a write or delete is seen at once in the same location, and in other locations up to 60 seconds later
826
+ (last write wins). Prefer keys that change with the data (a model as the key, or a version in an Array key) over deleting a key and expecting every location to miss.
827
+ - Writes to the same key are limited to 1 per second (more is a 429, which is logged and answered `false`).
828
+ - The Free plan allows 1,000 writes, 1,000 deletes and 100,000 reads a day, and a Worker invocation can make 1,000 KV operations.
829
+ Each `fetch` is one read, and one write on a miss; a `delete` counts against the deletes.
830
+ - A key is at most 512 bytes (handled by the store) and a value at most 25 MiB. KV refuses the keys `.` and `..`, so the `kv` store raises `ArgumentError` for them.
831
+ - Like D1, this needs JSPI.
832
+
833
+ ### In tests
834
+
835
+ `Edgeway.cache` is a `NullStore` in every test (`edgeway test`), whatever `config/cache.yml` says, so tests do not depend on each other.
836
+ A test of caching sets a store itself:
837
+
838
+ ```ruby
839
+ class StatsTest < Edgeway::IntegrationTest
840
+ def test_second_request_is_cached
841
+ Edgeway.cache = Edgeway::Cache::MemoryStore.new
842
+ get "/stats"
843
+ Todo.create!(title: "new")
844
+ get "/stats"
845
+ assert_equal 0, json["open"]
846
+ end
847
+ end
848
+ ```
849
+
850
+ ## Outbound HTTP
851
+
852
+ ```ruby
853
+ response = Edgeway::HTTP.get("https://api.example/items", headers: { "Authorization" => "Bearer #{token}" })
854
+ response = Edgeway::HTTP.post("https://api.example/items", body: JSON.generate({ "name" => "a" }),
855
+ headers: { "Content-Type" => "application/json" }, timeout: 5)
856
+ response.status # Integer
857
+ response.success? # 2xx
858
+ response.headers # Hash, names in lower case
859
+ response.body # String
860
+ response.json # the body parsed
861
+ ```
862
+
863
+ - `get` and `delete` take `headers:`; `post`, `put` and `patch` also take `body:` (a String, empty by default).
864
+ All take `timeout:` in seconds (default 10; from 0.001 to 300, otherwise `ArgumentError`).
865
+ A `body:` must be UTF-8 text; binary uploads are not supported.
866
+ - The answer is an `Edgeway::HTTP::ClientResponse`. A non-2xx status does not raise.
867
+ - Redirects are not followed: a 3xx comes back as it is, with its `location` header.
868
+ - Of repeated response headers only the last `Set-Cookie` is kept; other repeated headers are joined with ", ".
869
+ - The URL must be `https` (`http` only for `localhost`, `127.0.0.1`, `[::1]`); any other is an error.
870
+ - The response body is at most 1 MB; a larger one is an error. A binary body keeps its bytes but is labelled UTF-8 (`body.b` has the bytes).
871
+ - `Edgeway::HTTP::Error` is raised for a network failure, a refused URL and a response that is too large;
872
+ `Edgeway::HTTP::Timeout` (a subclass) when the timeout passes. Unrescued they are a 500, with the message only in the log.
873
+ - A Worker can make 50 subrequests per invocation on the Free plan and 10,000 by default on Paid,
874
+ and has at most 6 connections open at once.
875
+ - Like D1 and the JWT check, this needs JSPI.
876
+ - In tests, `stub_http` (see [Writing tests](#writing-tests)) stands in for the network, so no test makes a request.
877
+
878
+ ## Rate limiting
879
+
880
+ ```ruby
881
+ class PostsController < ApplicationController
882
+ before_action :authenticate!
883
+ rate_limit "WRITE_LIMITER", only: [:create, :update, :destroy], retry_after: 10
884
+ end
885
+ ```
886
+
887
+ The limit and the period are set on the binding in `wrangler.toml`, where Cloudflare reads them (the period is 10 or 60 seconds only; `namespace_id`
888
+ is a positive integer, as a string, unique in your account):
889
+
890
+ ```toml
891
+ [[ratelimits]]
892
+ name = "WRITE_LIMITER"
893
+ namespace_id = "1001"
894
+ [ratelimits.simple]
895
+ limit = 100
896
+ period = 60
897
+ ```
898
+
899
+ `wrangler dev` enforces the limit locally. `edgeway new` writes this commented out. It needs wrangler 4.36.0 or later (the generated app pins a newer one).
900
+
901
+ `rate_limit` is a `before_action` (run in declaration order), like Rails' `rate_limit`:
902
+
903
+ ```ruby
904
+ rate_limit "API_LIMITER", by: :rate_limit_key, name: nil, retry_after: nil, only: [...], except: [...]
905
+ ```
906
+
907
+ - The first argument is the binding name. A controller can have several, such as a 10 second and a 60 second binding.
908
+ - A request over the limit raises `Edgeway::TooManyRequests`: the answer is 429 with `{"error":"rate limit exceeded"}`.
909
+ `rescue_from Edgeway::TooManyRequests` answers otherwise; the error has `retry_after` (0 without `retry_after:`).
910
+ - `Retry-After` is sent only with `retry_after:` (a positive Integer, in seconds): the binding does not tell when the count resets.
911
+ - The key is `"<name>:<by>"`. `name:` defaults to the controller's class name, so every controller has its own count; give several controllers the same `name:` to share one.
912
+ - `by:` is a Symbol naming a controller method (not a lambda) that answers the key's second part, such as `:current_account_id`.
913
+ That method is never a route's action, even if it is public.
914
+ Don't key on a value the client sends (a header, a parameter): the client could pick a fresh count with every request.
915
+ - Without `by:`, the key is `"user:<sub>"` for a request that passed `authenticate!`, and otherwise `"ip:<client address>"`
916
+ (`request.remote_addr`, from `CF-Connecting-IP`).
917
+ An IPv6 address is keyed by its /64 prefix (`2001:db8:1:2::/64`), and an IPv4-mapped one (`::ffff:192.0.2.1`) by its IPv4 address.
918
+ - Order matters: a `rate_limit` declared after `before_action :authenticate!` never sees a request that `authenticate!` rejects (401), so token probing is not counted.
919
+ Declare an IP-keyed limit before `authenticate!` (it sees every request) and, if wanted, a per-user limit after it:
920
+
921
+ ```ruby
922
+ rate_limit "IP_LIMITER" # before authenticate!: no user is known yet, so the key is the IP; 401s are counted
923
+ before_action :authenticate!
924
+ rate_limit "USER_LIMITER" # after: the key is the user's sub
925
+ ```
926
+
927
+ - An empty key part (no client address, or a `by:` method answering `nil` or `""`) puts every such request in one shared count.
928
+ - The binding gets a SHA256 digest of the key, so the key is never sent as it is, and any bytes are safe in it.
929
+ - `wrangler dev` passes a `CF-Connecting-IP` sent by the client through, so a local test of the IP key can be spoofed; in production Cloudflare sets it.
930
+ - Cloudflare warns that an IP address can be shared by many users (a company, a mobile carrier), and that a limit on it can block them all.
931
+ Prefer a stable identifier such as a user or an API key.
932
+ - The key is never logged.
933
+
934
+ Counts are kept per Cloudflare location and are eventually consistent, so they are approximate: a burst can get past the limit,
935
+ and a client in another location has its own count. For a strict limit use a Durable Object. The limit is not shown in the Cloudflare dashboard;
936
+ look for the 429s in the logs.
937
+
938
+ A binding the Worker does not have (a typo in the name), or a value that has no `limit` function, is a misconfiguration:
939
+ the request raises `Edgeway::ConfigurationError` (500, the details only in the log), like a missing `JWT_ISSUER`, so a typo cannot switch limiting off silently.
940
+ Any error thrown by `limit()` itself lets the request through (fail open), including a binding of another kind that happens to have the name: it logs `rate limit check failed` with the `binding` and the `error`, and the request goes on.
941
+
942
+ Like D1, this needs JSPI.
943
+
944
+ ### In tests
945
+
946
+ No test is limited by default. A test turns a limit on for a binding, and reads the checks the app made:
947
+
948
+ ```ruby
949
+ class PostsControllerTest < Edgeway::IntegrationTest
950
+ def test_over_the_limit
951
+ stub_rate_limit("WRITE_LIMITER", exceeded: true)
952
+ post "/posts", json: { post: { title: "a" } }
953
+ assert_response 429
954
+ assert_equal "WRITE_LIMITER", rate_limit_checks.first[0] # [[binding, key], ...]
955
+ end
956
+ end
957
+ ```
958
+
959
+ The stub and the recorded checks start empty in every test.
960
+
961
+ A request has no client address unless the test gives one, so the default key is `ip:`; `remote_addr:` sets it for that request only:
962
+
963
+ ```ruby
964
+ get "/posts", remote_addr: "203.0.113.7"
965
+ assert_equal "PostsController:ip:203.0.113.7", rate_limit_checks.first[1]
966
+ ```
378
967
 
379
968
  ## Testing an app
380
969
 
381
970
  ```sh
382
971
  bundle exec edgeway test [ARGS] # ARGS go to spinel_test-unit
972
+ bin/test [FILE...] # in a generated app: the same, all of test/ without FILE
383
973
  ```
384
974
 
385
975
  `edgeway test` is the only supported way to run an app's tests.
976
+ `config/database.yml` decides how: with `adapter: d1` in its `test:` section (what `edgeway new` writes)
977
+ the tests are compiled to wasm32-wasi and run on a local D1, as in production; with any other adapter
978
+ (`memory`) they are a native build with an in-memory SQLite database (see [SQLite](#sqlite-adapter-memory-in-test)).
386
979
 
387
980
  - `spinel_test-unit` is the test framework (on RubyGems;
388
981
  `edgeway new` puts `gem "spinel_test-unit", "~> 0.1", group: :test` in the Gemfile).
@@ -394,15 +987,53 @@ bundle exec edgeway test [ARGS] # ARGS go to spinel_test-unit
394
987
 
395
988
  - Tests `require "edgeway/test"` in `test/test_helper.rb`.
396
989
  - Model tests inherit `Edgeway::UnitTest` (the database, no requests).
397
- - `Edgeway::IntegrationTest` inherits it and adds `get`/`post`/..., `json` and `assert_response`.
990
+ - `Edgeway::IntegrationTest` inherits it and adds `get`/`post`/..., `json` and `assert_response`. `get`/`post`/... take `json:`, `headers:` and `remote_addr:` (the client address, for that request only).
398
991
  - Both inherit `SpinelTest::Unit::TestCase`.
399
992
 
400
- Each test runs against a real in-memory SQLite database (`Edgeway::DB::SQLiteAdapter`, through Spinel's FFI)
401
- with `db/migrations/*.sql` applied in file name order, so requests really write and read rows.
993
+ Outgoing HTTP (see [Outbound HTTP](#outbound-http)) never reaches the network in a test:
402
994
 
403
- ### SQLite
995
+ ```ruby
996
+ stub_http(:get, "https://api.example/items", status: 200, body: '{"items":[]}', headers: { "Content-Type" => "application/json" })
997
+ stub_http_error(:post, "https://api.example/items", "connection refused") # raises Edgeway::HTTP::Error
998
+ stub_http_error(:post, "https://api.example/items", "timed out", timeout: true) # raises Edgeway::HTTP::Timeout
999
+ http_requests # the requests sent, in order: Hashes with "method", "url", "headers", "body" and "timeout_ms"
1000
+ ```
404
1001
 
405
- Running the tests requires SQLite (sqlite3). This needs the SQLite library at link time:
1002
+ - A request is matched on its method and exact URL; one without a stub raises `Edgeway::HTTP::UnstubbedRequest`.
1003
+ - The stubs and the recorded requests start empty in every test.
1004
+ - This is plain Ruby, so it works in the native and the wasm builds alike.
1005
+ - `Edgeway.cache` is a `NullStore` in every test (see [Caching](#caching)); a test of caching sets a `MemoryStore` itself.
1006
+ - `stub_rate_limit` and `rate_limit_checks` stand in for the limiter (see [Rate limiting](#rate-limiting)); no request is limited unless a test says so.
1007
+
1008
+ Each test starts from an empty database with `db/migrations/*.sql` applied in file name order
1009
+ (so the migrations' seed rows are there), and requests really write and read rows.
1010
+ Each database under `databases:` gets its own, with `db/NAME/migrations`; `db(:analytics)` answers it, and `db` the primary.
1011
+ `db.log` has the SQL the test ran.
1012
+
1013
+ ### Local D1 (`adapter: d1` in `test:`)
1014
+
1015
+ The tests are built like the app (`--target=wasm32-wasi`, with `--incremental`; the wasm is `tmp/test_app.wasm`) and run by Node.js
1016
+ (`js/cloudflare-test-runner.js`, copied to `tmp/edgeway/` with the other `js/` files) on the D1 that wrangler's `getPlatformProxy` starts,
1017
+ the one `wrangler dev` uses, but in memory (`persist: false`): `.wrangler/state` is never touched.
1018
+
1019
+ - Before each test every table and view is dropped (except `sqlite_*`, `_cf_*` and `d1_migrations`) and the migrations are applied again,
1020
+ so a test cannot see another's rows and the ids start from 1.
1021
+ Foreign keys are enforced as in D1.
1022
+ - The bindings are the `binding:` of `test:` and of its `databases:`, which must exist in `wrangler.toml`.
1023
+ Give each its own `database_id`: bindings with the same id are the same local database.
1024
+ Databases that share a binding are one database: it is emptied once and the migrations of each are applied, the primary's first.
1025
+ Only the databases in `test:` exist in the tests (not those of other environments).
1026
+ - The Worker variables of `wrangler.toml` (`JWT_*`, `APP_*`) are the app's settings, as in production.
1027
+ - Needs Node.js 24 or later (`edgeway test` passes `--experimental-wasm-jspi --experimental-wasm-exnref` to Node.js 24, which needs them),
1028
+ wrangler in `node_modules` (`pnpm install`, or `bin/setup`), `wrangler.toml`, the WASI SDK (`WASI_SDK`) and Spinel.
1029
+ It stops with the fix when one is missing.
1030
+ - The first build of an app takes a while; later ones reuse Spinel's cached C units.
1031
+
1032
+ ### SQLite (`adapter: memory` in `test:`)
1033
+
1034
+ The tests are a native build with a real in-memory SQLite database per test (`Edgeway::DB::SQLiteAdapter`, through Spinel's FFI).
1035
+ Every database under `databases:` (in any environment) gets one.
1036
+ This needs the SQLite library at link time:
406
1037
  `libsqlite3-dev` on Debian/Ubuntu, `sqlite-devel` on Fedora (preinstalled on macOS).
407
1038
 
408
1039
  - `edgeway test` first tries to link `-lsqlite3` with `$CC` (or `cc`/`gcc`/`clang`) and stops with the fix if that fails.
@@ -413,22 +1044,50 @@ Edgeway's own tests use `MemoryAdapter`, which stores nothing; `SQLiteAdapter` i
413
1044
 
414
1045
  ### Differences from production
415
1046
 
416
- The tests are native builds, while production is wasm32-wasi on Workers.
1047
+ With a local D1 the tests are wasm on a D1 as in production, and JSPI and `Edgeway::Cloudflare::D1Adapter` are exercised.
1048
+ What differs:
1049
+
1050
+ - It is wrangler's local D1 (miniflare), not Cloudflare's: its limits and timing are not reproduced, and neither is read replication
1051
+ (`sessions: true` is passed on, but there is one local replica).
1052
+ - The app is called directly (`get`, `post`), not through `js/cloudflare.js` and the Worker: no CORS, no `X-D1-Bookmark`.
1053
+ - A JWT's signature is checked only when the test sets `Edgeway::Auth.verifier = Edgeway::Cloudflare::JWTVerifier.new`
1054
+ (`JWT_SECRET` and the like come from the Worker variables, `.dev.vars` included); otherwise use `Edgeway::Auth::StubVerifier`.
1055
+ - `db.log` has the SQL the test ran, but not the migrations'.
1056
+
1057
+ With SQLite the tests are native builds, while production is wasm32-wasi on Workers.
417
1058
 
418
1059
  - D1 (`Edgeway::Cloudflare::D1Adapter`) and JSPI are not exercised,
419
1060
  and neither is a JWT's signature check (use `Edgeway::Auth::StubVerifier`).
420
- - `SQLiteAdapter` is loaded only by `edgeway/test`: `edgeway build` never requires `edgeway/db/sqlite`,
1061
+ - `SQLiteAdapter` is loaded only by `edgeway test`: `edgeway build` never requires `edgeway/db/sqlite`,
421
1062
  because libsqlite3 can't be linked into the wasm.
422
1063
  - SQLite and D1 (itself SQLite) take the same SQL, but D1's own limits
423
1064
  (such as the number of bound parameters) are not reproduced.
424
1065
 
1066
+ ## Known limitations
1067
+
1068
+ - Only the subset of Ruby that Spinel supports can be used.
1069
+ - `has_many` does not work with namespaced models (`Admin::Post`);
1070
+ `belongs_to` and `has_many` look models up by their full name, and that case is not handled.
1071
+ - Cloudflare Access is not supported: its token comes in `Cf-Access-Jwt-Assertion`,
1072
+ and only `Authorization: Bearer` is read (see [Authentication](#authentication)).
1073
+ - Tests call the app directly, not through the Worker, so CORS and `X-D1-Bookmark` are not covered.
1074
+ With `adapter: memory` in `test:` they are a native build, and D1 and JSPI are not covered either (see [Testing an app](#testing-an-app)).
1075
+ - Not verified on a production Cloudflare deploy yet: CPU and wall time on a cold start and on a JWKS cache miss,
1076
+ and read replication with real replicas (local D1 has none, and D1 itself accepts any bookmark there). Everything was checked under `wrangler dev`.
1077
+ - Integers are 32 bits in a wasm build: use `:bigint` for larger ones and pass them as Strings,
1078
+ and keep them below 2^53, as D1 hands rows over as JavaScript numbers (see [Attribute types](#attribute-types)).
1079
+ - A `Model.batch` runs on one database: there is no transaction across databases ([Multiple databases](#multiple-databases)).
1080
+ - `Model.exec` does not work on a database with `sessions: true`: a D1 session has no `exec()` ([Queries](#queries)).
1081
+ - Spinel can't build a Range of Times: use ISO 8601 Strings or a SQL fragment in `where` ([Queries](#queries)).
1082
+ - `first()` and `raw()` are not used and `dump()` is not supported; Time Travel and export/import are `wrangler d1` commands ([D1 API coverage](#d1-api-coverage)).
1083
+
425
1084
  ## Development
426
1085
 
427
1086
  ```sh
428
1087
  # mise install (Spinel, the WASI SDK) and bundle install
429
1088
  bin/setup
430
1089
 
431
- # needs libsqlite3-dev (test/spinel/sqlite.rb);
1090
+ # needs libsqlite3-dev (test/spinel/sqlite.rb) and, for the Node.js tests, node;
432
1091
  # runs test/*.rb under CRuby and as Spinel native builds, and compares with .expected
433
1092
  bundle exec rake test
434
1093