edgeway 0.1.0 → 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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +270 -9
  4. data/js/cloudflare-d1-sessions.js +68 -0
  5. data/js/cloudflare.js +123 -22
  6. data/js/cors.js +15 -16
  7. data/js/headers.js +16 -0
  8. data/js/jwt.js +1 -1
  9. data/lib/edgeway/application.rb +6 -3
  10. data/lib/edgeway/cli/build/app_config.rb +39 -0
  11. data/lib/edgeway/cli/build/app_files.rb +40 -0
  12. data/lib/edgeway/cli/build/database_config.rb +92 -0
  13. data/lib/edgeway/cli/build/view_compiler.rb +191 -0
  14. data/lib/edgeway/cli/build.rb +36 -11
  15. data/lib/edgeway/cli/generate.rb +174 -26
  16. data/lib/edgeway/cli/new.rb +1 -1
  17. data/lib/edgeway/cli/test.rb +4 -1
  18. data/lib/edgeway/cli.rb +5 -4
  19. data/lib/edgeway/controller/base.rb +12 -2
  20. data/lib/edgeway/controller/dispatcher.rb +11 -1
  21. data/lib/edgeway/controller/rendering.rb +24 -4
  22. data/lib/edgeway/db/adapter.rb +38 -0
  23. data/lib/edgeway/db/memory.rb +9 -2
  24. data/lib/edgeway/db/query_stats.rb +46 -0
  25. data/lib/edgeway/db/sqlite.rb +73 -9
  26. data/lib/edgeway/errors.rb +22 -0
  27. data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
  28. data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
  29. data/lib/edgeway/generators/app/templates/README.md.tt +6 -2
  30. data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
  31. data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
  32. data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
  33. data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
  34. data/lib/edgeway/generators/app/templates/config/database.yml +17 -0
  35. data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
  36. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +7 -0
  37. data/lib/edgeway/http/auth/stub.rb +1 -3
  38. data/lib/edgeway/http/request.rb +2 -22
  39. data/lib/edgeway/inflector.rb +19 -0
  40. data/lib/edgeway/model/attributes.rb +93 -10
  41. data/lib/edgeway/model/base.rb +50 -5
  42. data/lib/edgeway/model/connection_registry.rb +25 -0
  43. data/lib/edgeway/model/macros.rb +5 -1
  44. data/lib/edgeway/model/persistence.rb +18 -11
  45. data/lib/edgeway/model/relation.rb +106 -56
  46. data/lib/edgeway/model/validations.rb +29 -23
  47. data/lib/edgeway/routing/route.rb +1 -6
  48. data/lib/edgeway/server.rb +12 -0
  49. data/lib/edgeway/service/cloudflare/d1.rb +39 -10
  50. data/lib/edgeway/test.rb +29 -8
  51. data/lib/edgeway/version.rb +1 -1
  52. data/lib/edgeway/view.rb +24 -0
  53. data/lib/edgeway.rb +3 -0
  54. metadata +17 -4
  55. data/lib/edgeway/cli/app_config.rb +0 -35
  56. data/lib/edgeway/cli/app_files.rb +0 -37
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f7271c053efc7a3ce130edd6347262ddc8166650a6bc70cc172c30befe3c5372
4
- data.tar.gz: d0b25d4d34619575a7933aad50bed1c4885da6d8078bfa2308e0c8004da6ab8a
3
+ metadata.gz: 9800b1c381a9b0eabd63071ed84bec91abb5ac85e972695bd552a5ba67aca46c
4
+ data.tar.gz: b04ff64800edf5baa49a556309691839d155fbfce3246210e49f775431afd0ee
5
5
  SHA512:
6
- metadata.gz: 7859d0d22cb99f390c079bdd265567da3cba5c10f0a417a185f62551f322b118972107be922a79960c95b39b0be718a0999acb2130ddccf51fa38ede989ee391
7
- data.tar.gz: 8da347d44a4e0debb86aa4b0aa3a247fdca6063da45315b3bfddc28ac112a966c1ef2dd2020165f7ba98c1f5e789a8929424eef9a1d0c37efb607a70305d15db
6
+ metadata.gz: 131183da9841be36432cbe37a23dd905e167411311c70dbaee9a2fb87c65c2ab6f156dcc732b0fa17b8ae47ef167e58c57c685953309aa83dec138887f746950
7
+ data.tar.gz: 948c56cb42f175a146051d571d5927f44232c04857535c5d887eb5fd60a69514ecc22ad8e1b3a182a88f8835b180804c17670e23a9fbb7bb6a1aa2b0e947a460
data/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## v0.2.0
6
+
7
+ ### Added
8
+
9
+ - Views: ERB templates in `app/views`. `<%= %>` escapes unless the value is `raw(text)` / `text.html_safe`, and `render html:` escapes anything but a `SafeHTML`. An action that renders nothing renders its template when there is one (else 204 as before), and `render` raises `Edgeway::MissingTemplate` when its template is missing ([#1](https://github.com/y-yagi/edgeway/pull/1)).
10
+ - `edgeway generate scaffold`: a model, its migration, a JSON API controller and their tests, and `r.resources` added to `config/routes.rb`.
11
+ `generate model` and `scaffold` stop if the table's create migration exists ([#2](https://github.com/y-yagi/edgeway/pull/2)).
12
+ - Apps made by `edgeway new` get `bin/test`, a GitHub Actions workflow and a `.ruby-version`.
13
+ - D1:
14
+ - `NotNullViolation`, `InvalidForeignKey` and `CheckViolation` (500), and `DatabaseUnavailable` (503 with `Retry-After: 1`) for D1's transient errors ([#4](https://github.com/y-yagi/edgeway/pull/4)).
15
+ - Raw SQL (`find_by_sql`, `Edgeway::Model.select_all`, `Edgeway::Model.execute`), and `where` with Arrays (`IN`), Ranges and SQL fragments.
16
+ More than 100 bound parameters in a statement raises `ArgumentError` (`limit` and `offset` don't count) ([#5](https://github.com/y-yagi/edgeway/pull/5)).
17
+ - `Edgeway::Model.query_stats` and a `db=… rows_read=… rows_written=… db_ms=…` log line per request ([#6](https://github.com/y-yagi/edgeway/pull/6)).
18
+ - `:binary` and `:bigint` attribute types; a rowid beyond 32 bits works. `Edgeway::Model.blob` binds a BLOB in raw SQL,
19
+ and `Edgeway::Model.cast(:bigint, value)` reads a large integer from a raw row ([#7](https://github.com/y-yagi/edgeway/pull/7)).
20
+ - Several databases: `databases:` in `config/database.yml` and `self.database = :name`; `database:` for `select_all`/`execute`,
21
+ `query_stats(:name)`, and `db(:name)` in tests ([#9](https://github.com/y-yagi/edgeway/pull/9)).
22
+ - Read replication through the Sessions API: `sessions: true`, with the bookmark in an `X-D1-Bookmark` header
23
+ (added to `Vary` and, with CORS, to the allowed and exposed headers); only `GET`/`HEAD` start from it, other methods always start `first-primary` ([#10](https://github.com/y-yagi/edgeway/pull/10)).
24
+ - `Edgeway::Model.exec` for D1's `exec()` ([#11](https://github.com/y-yagi/edgeway/pull/11)).
25
+
26
+ ### Changed
27
+
28
+ - `edgeway build` passes Spinel's `--incremental`; `--release` builds as one unit, and the generated `bin/deploy` and CI use it ([#8](https://github.com/y-yagi/edgeway/pull/8)).
29
+ - The Spinel that Edgeway and the `mise.toml` of `edgeway new` pin is `ec0ad13e4` (was `530ba2b17`) ([#3](https://github.com/y-yagi/edgeway/pull/3), [#8](https://github.com/y-yagi/edgeway/pull/8)).
30
+ - A Time in a query is bound as ISO 8601 UTC with milliseconds (`2026-10-28T05:00:00.000Z`), as timestamps are stored ([#5](https://github.com/y-yagi/edgeway/pull/5)).
31
+
32
+ ## 0.1.0
33
+
34
+ - First release.
data/README.md CHANGED
@@ -39,12 +39,20 @@ end
39
39
  ## Contents
40
40
 
41
41
  - [Models](#models)
42
+ - [Attribute types](#attribute-types)
42
43
  - [Strong parameters](#strong-parameters)
43
44
  - [Uniqueness](#uniqueness)
45
+ - [Database errors](#database-errors)
44
46
  - [Ordering and deleting](#ordering-and-deleting)
47
+ - [Queries](#queries)
48
+ - [Query stats](#query-stats)
45
49
  - [Batch](#batch)
46
50
  - [Associations](#associations)
47
51
  - [Eager loading](#eager-loading)
52
+ - [Multiple databases](#multiple-databases)
53
+ - [Read replication (Sessions)](#read-replication-sessions)
54
+ - [D1 API coverage](#d1-api-coverage)
55
+ - [Views](#views)
48
56
  - [Usage](#usage)
49
57
  - [Generators](#generators)
50
58
  - [Building](#building)
@@ -59,6 +67,45 @@ end
59
67
 
60
68
  ## Models
61
69
 
70
+ ### Attribute types
71
+
72
+ | Type | Ruby value | SQLite column | In JSON |
73
+ | --- | --- | --- | --- |
74
+ | `:string`, `:text` | String | TEXT | string |
75
+ | `:integer` | Integer (32 bits) | INTEGER | number |
76
+ | `:float` | Float | REAL | number |
77
+ | `:boolean` | `true`/`false` | INTEGER (1/0) | `true`/`false` |
78
+ | `:datetime` | String, ISO 8601 UTC | TEXT | string |
79
+ | `:binary` | binary String (`.b`) | BLOB | base64 string |
80
+ | `:bigint` | decimal String (`"3000000000"`) | INTEGER | string |
81
+
82
+ - Integers are 32 bits in a wasm build. A `:integer` value beyond -2147483648..2147483647 casts to `nil`; use `:bigint` for those columns.
83
+ Assigning one is refused by validation ("is out of range"). A row that already holds one (a foreign key to a bigint parent
84
+ declared as `:integer`) loads as `nil`, and saving it fails the same way instead of writing `NULL` over it.
85
+ - A `:bigint` holds its value as a String of an optional `-` and digits, without leading zeros (`"-0"` is `"0"`);
86
+ anything else, or a value beyond 64 bits, casts to `nil` ("is invalid"). It is bound as text and SQLite's INTEGER affinity
87
+ stores it as an integer, so `where`, `order` and comparisons work on the number.
88
+ It renders in JSON as a String, which JavaScript clients read exactly.
89
+ D1 hands rows over as JavaScript numbers, so a value beyond 2^53 (9007199254740991) has already lost precision before Ruby sees it.
90
+ Keep such values below 2^53, or store them as `:string`.
91
+ - Pass bigint values as Strings: `Todo.find("3000000000")`, `where(id: "3000000000")`, `create!(owner_id: "5000000000")`,
92
+ `Model.execute("... WHERE id = ?", ["3000000000"])`. A Hash holding an Integer literal beyond 32 bits raises `RangeError` in a wasm build
93
+ (a Spinel limitation), so do not write them as Integers.
94
+ - `attribute :id, :bigint` makes the primary key a bigint: `find`, `where(id: ...)`, `update`, `destroy` and an insert
95
+ (`RETURNING id`) work past 2^31, and `id` is a String. A foreign key to it is `attribute :owner_id, :bigint`
96
+ (declare it before `belongs_to` and it keeps its type).
97
+ - Rows from raw SQL (`Model.select_all`) hold what the database gave: in a wasm build an INTEGER beyond 32 bits is a Bignum,
98
+ and `render json:` of it raises. Select it as text (`SELECT CAST(id AS TEXT) AS id ...`) or pass it through `Edgeway::Model.cast(:bigint, value)`.
99
+ - A `:binary` attribute holds a binary String. Assigned from Ruby, a String keeps its bytes (`Document.new(body: bytes)`), and so does a `{"$blob" => base64}` Hash.
100
+ It is bound as a BLOB (`where(body: bytes)` too), and renders in JSON as a base64 String.
101
+ In a request body the value is that base64 String, so a GET then PATCH round trip is stable: params assigned through `permit` are decoded,
102
+ and invalid base64 fails validation ("is invalid"). `{"$blob": ...}` objects are not supported in a request body (nested objects are dropped).
103
+ `presence` counts a binary String blank only when empty, and `length` counts bytes.
104
+ A BLOB crosses between wasm and JS as `{"$blob": "<base64>"}`: binds and rows cross as JSON text, which can't hold arbitrary bytes.
105
+ In raw SQL (`Model.select_all`, `Model.execute`, `where("...", value)`) bind a BLOB with `Edgeway::Model.blob(bytes)`,
106
+ and a BLOB column comes back as `{"$blob" => base64}`.
107
+ - `Model.execute` answers `"last_insert_id"` as an Integer, or as a decimal String when the rowid is beyond 32 bits.
108
+
62
109
  ### Strong parameters
63
110
 
64
111
  Params must go through `permit` before a model takes them.
@@ -92,6 +139,22 @@ Project.where(owner_id: uid).find_or_create_by(name: "default")
92
139
  the database's message (table and columns) goes only to the log.
93
140
  - A controller can `rescue_from Edgeway::RecordNotUnique` to answer otherwise.
94
141
 
142
+ ### Database errors
143
+
144
+ All are `Edgeway::DatabaseError`; the database's message goes only to the log.
145
+
146
+ - `NotNullViolation`, `InvalidForeignKey` and `CheckViolation` (a constraint the app broke):
147
+ answered 500 `{"error":"internal error"}`, like any other `DatabaseError`.
148
+ - `DatabaseUnavailable` (D1 lost its connection, was reset or is overloaded):
149
+ answered 503 `{"error":"database unavailable"}` with `Retry-After: 1`.
150
+ - A controller can `rescue_from` any of them to answer otherwise.
151
+ `rescue_from Edgeway::DatabaseError` also catches `DatabaseUnavailable` and `RecordNotUnique`,
152
+ so it replaces their default 503 and 409.
153
+ - D1 retries read-only queries itself. Edgeway never retries a query, a write or a batch.
154
+ - A write that got a 503 may still have been applied:
155
+ only an idempotent request (a GET, or a write the app made idempotent) is safe to retry.
156
+ - Timeouts and CPU or memory limits stay a plain `DatabaseError` (500).
157
+
95
158
  ### Ordering and deleting
96
159
 
97
160
  ```ruby
@@ -115,6 +178,60 @@ class Todo < ApplicationModel
115
178
  end
116
179
  ```
117
180
 
181
+ ### Queries
182
+
183
+ ```ruby
184
+ Todo.where(id: [1, 2]) # id IN (?, ?)
185
+ Todo.where(position: 1..5) # position >= ? AND position <= ?
186
+ Todo.where(position: (3..)) # position >= ?
187
+ Todo.where("title LIKE ?", "%x%") # (title LIKE ?)
188
+ Todo.where("done = 1")
189
+
190
+ Todo.find_by_sql("SELECT * FROM todos WHERE done = ?", [true]) # records
191
+ Edgeway::Model.select_all("SELECT COUNT(*) AS n FROM todos", []) # row Hashes
192
+ Edgeway::Model.execute("DELETE FROM todos WHERE done = ?", [true]) # {"changes" => n, "last_insert_id" => id}
193
+ Edgeway::Model.exec("DELETE FROM tags\nDELETE FROM todos") # nil
194
+ ```
195
+
196
+ - `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(...)`.
197
+ - An Array is `IN`; each value is cast by the column type. An empty Array matches nothing (`1 = 0`).
198
+ A `nil` in an Array also matches `NULL`: `(position IN (?) OR position IS NULL)`.
199
+ - A Range is `>=` and `<=` (`<` for `1...5`); an endless or beginless Range has one side.
200
+ Spinel can't build a Range of Times: use ISO 8601 Strings (`"2026-10-28"..."2026-10-29"`) or a fragment.
201
+ - A String is a raw SQL fragment, in parentheses and ANDed with the rest; its binds follow it.
202
+ It is raw SQL: put user input in the binds, never interpolate it.
203
+ Its binds are not cast by column type: only booleans (1/0) and Times (ISO 8601) are converted.
204
+ An Array bind raises `ArgumentError` (not expanded): use `where(column: [...])`.
205
+ - `build`/`create`/`find_or_create_by` on a relation copy only the plain equality conditions.
206
+ - `find_by_sql` makes records of the rows, so select the model's columns (`SELECT *` or each of them): a missing one is `nil`.
207
+ - `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.
208
+ `execute` is a write, so inside `Model.batch` it is queued (and answers 0 counts).
209
+ - `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.
210
+ A statement must fit on one line (D1 splits the text at newlines); `a; b` on one line also works. Empty SQL raises `ArgumentError`.
211
+ When a statement fails, none of them stay applied (seen on local D1; the SQLite adapter in tests does the same).
212
+ D1's error message names the first line, not the one that failed.
213
+ It can't run inside `Model.batch` (`ArgumentError`), and fails on a database with `sessions: true` (`DatabaseError`): a D1 session has no `exec()`.
214
+ - D1 allows 100 bound parameters per statement: more raises `ArgumentError`, in every adapter (`limit` and `offset` are literals, not binds).
215
+ `where(id: ids)` with over 100 ids needs chunking.
216
+
217
+ ### Query stats
218
+
219
+ `Edgeway::Model.query_stats` (it raises `ConfigurationError` without a connection) is what D1 reported for the queries of the current request
220
+ (a wasm instance serves one request, so it starts at zero):
221
+
222
+ - `queries`: the number of successful D1 calls (a `Model.batch` is one). A failed call and its time are not counted.
223
+ - `rows_read`, `rows_written`: the sums of D1's `meta`, capped at 2147483647 (32-bit; the log line shows a capped count with a trailing `+`).
224
+ - `duration_ms` (Float): the sum of D1's `meta.duration`.
225
+ - `served_by_region` and `served_by_primary`: from the last call (`""` and `true` when D1 doesn't say, as in `wrangler dev`).
226
+
227
+ When a request ran any query, `Edgeway::Server` logs one line after it, also for a request that raised and was rescued:
228
+
229
+ ```
230
+ [edgeway] db=3 rows_read=12 rows_written=1 db_ms=2.46
231
+ ```
232
+
233
+ The Memory and SQLite adapters report zeros and log nothing.
234
+
118
235
  ### Batch
119
236
 
120
237
  `Edgeway::Model.batch` makes several writes atomic.
@@ -179,6 +296,116 @@ Todo.eager_load(:comments, :tags) # several at once, still one query
179
296
  - Only `to_a` and what is built on it (`each`, `map`, `size`) read the loaded records;
180
297
  `comments.where(...)`, `count` and `first` query as usual.
181
298
 
299
+ ### Multiple databases
300
+
301
+ A model uses the primary database unless it names another, declared in `config/database.yml` (see "Configuration"):
302
+
303
+ ```ruby
304
+ class Event < ApplicationModel
305
+ self.database = :analytics # inherited by subclasses
306
+ attribute :name, :string
307
+ end
308
+
309
+ Event.create!(name: "signup")
310
+ Edgeway::Model.select_all("SELECT COUNT(*) AS n FROM events", [], database: :analytics)
311
+ Edgeway::Model.execute("DELETE FROM events", [], database: :analytics)
312
+ ```
313
+
314
+ - Every query and write of a model goes to its database, including `find_by_sql`.
315
+ `Model.select_all` and `Model.execute` use the primary unless given `database:`.
316
+ - Associations between models on different databases work as separate queries.
317
+ A `has_many` with `dependent:` to a model on another database raises `ArgumentError` on `destroy`, before any query: it can't be atomic.
318
+ `eager_load` across databases raises `ArgumentError`: a `JOIN` can't cross them.
319
+ - A `Model.batch` belongs to the database of its first write; a write for another one raises `ArgumentError`
320
+ (D1 batches are per database). Use one `batch` per database.
321
+ - `Edgeway::Model.query_stats(:analytics)` is that database's stats. The request log has one line per database that ran queries,
322
+ and the extra ones start with `database=analytics`.
323
+
324
+ ### Read replication (Sessions)
325
+
326
+ With D1 read replication enabled for a database (dashboard, or `PUT` its settings), reads can be served by a replica near the user.
327
+ 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`:
328
+
329
+ ```yaml
330
+ production:
331
+ adapter: d1
332
+ binding: DB
333
+ sessions: true
334
+ databases:
335
+ analytics:
336
+ binding: ANALYTICS
337
+ sessions: true
338
+ ```
339
+
340
+ `sessions:` must be `true` or `false` (anything else fails the build), and does nothing with the `memory` adapter.
341
+ Every query and `Model.batch` of such a database then goes through `env.DB.withSession(...)`, created on first use and kept for the request.
342
+
343
+ - 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.
344
+ - The response has `X-D1-Bookmark: DB=<bookmark>, ANALYTICS=<bookmark>`: a `BINDING=bookmark` pair for each session the request used (also with one database).
345
+ - 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.
346
+ - 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.
347
+ - Responses of an app with sessions get `Vary: X-D1-Bookmark`.
348
+ - 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`).
349
+ - `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).
350
+
351
+ ### D1 API coverage
352
+
353
+ | D1 Worker API | In Edgeway |
354
+ | --- | --- |
355
+ | `prepare().bind().all()` | every query, `find_by_sql`, `Model.select_all`, and inserts (`INSERT ... RETURNING id`) |
356
+ | `prepare().bind().run()` | updates and deletes, `Model.execute` |
357
+ | `batch()` | `Model.batch` (and `destroy` with dependents) |
358
+ | `exec()` | `Model.exec` |
359
+ | `withSession()`, `getBookmark()` | `sessions: true` ([Read replication](#read-replication-sessions)) |
360
+ | `result.meta` | `Model.query_stats` and the `db=…` log line |
361
+ | `first()`, `raw()` | not used: `find_by`/`first` send `LIMIT 1` through `all()`, which also keeps `meta`; `raw()` made the JSON smaller but rebuilding the rows in Ruby cost as much as it saved |
362
+ | `dump()` | not supported (alpha databases only) |
363
+
364
+ Time Travel, export/import and migrations are `wrangler d1` commands, outside the Worker API.
365
+
366
+ ## Views
367
+
368
+ ```ruby
369
+ # app/controllers/todos_controller.rb
370
+ class TodosController < ApplicationController
371
+ def index
372
+ @todos = Todo.order(:id).limit(50)
373
+ # renders app/views/todos/index.html.erb
374
+ end
375
+ end
376
+ ```
377
+
378
+ ```erb
379
+ <%# app/views/todos/index.html.erb %>
380
+ <ul>
381
+ <% @todos.each do |t| -%>
382
+ <li><%= t.title %></li>
383
+ <% end -%>
384
+ </ul>
385
+ ```
386
+
387
+ Spinel has no `eval`, so `edgeway build` (and `edgeway test`) compile `app/views/**/*.html.erb` into Ruby
388
+ (`build/views.rb`, `tmp/views.rb`): each template becomes a method of its controller,
389
+ so it sees the action's `@variables`, `params` and the controller's methods.
390
+
391
+ - `<%= expr %>` is HTML-escaped (`Edgeway::View.escape`) unless the value is `raw(text)` or `text.html_safe`
392
+ (an `Edgeway::View::SafeHTML`); `<%== expr %>` is never escaped.
393
+ Safety is not kept across string operations (`"<b>".html_safe + x` is not defined): build the HTML in a template,
394
+ or escape the pieces with `Edgeway::View.escape` and mark the result.
395
+ `<% code %>`, `<%# comment %>`, `<%%` (a literal `<%`) and `-%>` (drops the newline after the tag) work as in ERB.
396
+ - The public methods of `Edgeway::Controller::Base` (`render`, `head`, `params`, `raw`, ...) are not actions; a route to one is a 404.
397
+ - An action that renders nothing renders its template, or answers 204 when it has none.
398
+ - `render :name` / `render "name"` renders another template of the controller, `render html: value` a string (HTML-escaped; a `SafeHTML` is output as is);
399
+ both take `status:` and `layout: false`.
400
+ A name with no template raises `Edgeway::MissingTemplate` (a 500).
401
+ - `app/views/layouts/application.html.erb` wraps every HTML response, at `<%= yield %>`. `edgeway new` writes one.
402
+ - HTML responses are `Content-Type: text/html; charset=utf-8` with `X-Content-Type-Options: nosniff`.
403
+ - A template's directory names its controller (`app/views/admin/posts/` is `Admin::PostsController`);
404
+ one without a controller is a build error, and so is a syntax error in a template,
405
+ which Spinel reports in the generated file (its `# app/views/...:LINE` comments point back).
406
+
407
+ Not supported yet: partials (`render partial:`), layouts other than `application`, and templates other than `.html.erb`.
408
+
182
409
  ## Usage
183
410
 
184
411
  ```sh
@@ -190,15 +417,18 @@ edgeway new myapp
190
417
  cd myapp && bin/setup
191
418
 
192
419
  # the app's tests, natively (needs libsqlite3-dev, see "Testing an app")
193
- bundle exec edgeway test
420
+ bundle exec edgeway test # or bin/test [FILE...] in a generated app
194
421
 
195
- # edgeway build: writes build/app.wasm and build/edgeway/ (the JS)
422
+ # edgeway build: writes build/app.wasm and build/edgeway/ (the JS).
423
+ # A development build: Spinel's --incremental caches the generated C in units,
424
+ # so a rebuild recompiles only what changed (no inlining across units)
196
425
  bin/build
197
426
 
198
427
  pnpm exec wrangler dev
199
428
  edgeway routes
200
429
 
201
- # bin/build, remote D1 migrations, then pnpm exec wrangler deploy
430
+ # bin/build --release (one unit, without --incremental), remote D1 migrations,
431
+ # then pnpm exec wrangler deploy
202
432
  bin/deploy
203
433
  ```
204
434
 
@@ -213,6 +443,7 @@ Before `bin/deploy`:
213
443
  `bin/setup` prints these steps when they are missing.
214
444
 
215
445
  - A generated app's Gemfile takes `edgeway` and `spinel_test-unit` from RubyGems.
446
+ - A generated app's `.github/workflows/ci.yml` installs libsqlite3-dev, Ruby (from `.ruby-version`, which `edgeway new` writes with the Ruby that ran it), and mise's tools, then runs `bin/test` and `bin/build --release`.
216
447
  - An app uses [pnpm](https://pnpm.io/) for wrangler (`package.json`, `bin/setup`, `mise.toml`);
217
448
  `edgeway new` names it in one place (`Edgeway::CLI::PackageManager`).
218
449
  - `SPINEL=/path/to/spinel bin/build` uses a Spinel other than the one on `PATH`.
@@ -228,13 +459,19 @@ edgeway generate model Todo title:string done:boolean
228
459
 
229
460
  # app/controllers/todos_controller.rb, test/controllers/todos_test.rb
230
461
  edgeway generate controller Todos index show
462
+
463
+ # the model's files, a JSON API controller (index, show, create, update, destroy) and its test
464
+ edgeway generate scaffold Todo title:string done:boolean
231
465
  ```
232
466
 
233
467
  - Field types are `string` (the default), `text`, `integer`, `float`, `boolean` and `datetime`.
234
468
  - A model gets `timestamps` and an `id`, `created_at` and `updated_at` column.
235
469
  - The migration is numbered one above the highest in `db/migrations`.
236
470
  - 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`).
471
+ - `config/routes.rb` is not changed by `model` and `controller`: the command prints the route to add (`r.resources :todos`).
472
+ - `model` and `scaffold` stop if a `db/migrations/*_create_todos.sql` already exists (a rerun would add a second table).
473
+ - `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`.
474
+ - The scaffold's controller permits the given fields (`params.require(:todo).permit(...)`), and with no fields it takes no params.
238
475
 
239
476
  ### Building
240
477
 
@@ -254,7 +491,7 @@ Edgeway needs a Spinel newer than the 2026.09.12 release
254
491
  (that release refuses `send` with a computed name, which `Callbacks#run_before_actions` uses).
255
492
 
256
493
  - `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.
494
+ `ec0ad13e4`, which mise-spinel builds from source.
258
495
  A commit rather than `master`, so that an install is the Spinel that was tested.
259
496
  - The WASI SDK (`wasi-sdk-34`) is installed first,
260
497
  because mise-spinel builds Spinel's wasm32-wasi runtime only when it finds one.
@@ -269,8 +506,14 @@ Edgeway needs a Spinel newer than the 2026.09.12 release
269
506
  and only `Authorization: Bearer` is read (see [Authentication](#authentication)).
270
507
  - Tests run natively, not in wasm: D1 and JSPI (`Edgeway::Cloudflare::JWTVerifier`) are not covered by them,
271
508
  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`.
509
+ - Not verified on a production Cloudflare deploy yet: CPU and wall time on a cold start and on a JWKS cache miss,
510
+ and read replication with real replicas (local D1 has none, and D1 itself accepts any bookmark there). Everything was checked under `wrangler dev`.
511
+ - Integers are 32 bits in a wasm build: use `:bigint` for larger ones and pass them as Strings,
512
+ and keep them below 2^53, as D1 hands rows over as JavaScript numbers (see [Attribute types](#attribute-types)).
513
+ - A `Model.batch` runs on one database: there is no transaction across databases ([Multiple databases](#multiple-databases)).
514
+ - `Model.exec` does not work on a database with `sessions: true`: a D1 session has no `exec()` ([Queries](#queries)).
515
+ - Spinel can't build a Range of Times: use ISO 8601 Strings or a SQL fragment in `where` ([Queries](#queries)).
516
+ - `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)).
274
517
 
275
518
  ## Configuration
276
519
 
@@ -281,6 +524,22 @@ Two files configure the database, each for its own side:
281
524
  - `wrangler.toml` is Cloudflare's: which D1 database the binding is (`database_name`, `database_id`),
282
525
  the migrations, and the Worker. `binding` in it must match `binding` in `config/database.yml`.
283
526
 
527
+ Another database goes under `databases:` of an environment, with its own binding in `wrangler.toml`:
528
+
529
+ ```yaml
530
+ production:
531
+ adapter: d1
532
+ binding: DB
533
+ databases:
534
+ analytics:
535
+ binding: ANALYTICS
536
+ ```
537
+
538
+ Its migrations are in `db/analytics/migrations` (`migrations_dir` of its `[[d1_databases]]`; apply them per database).
539
+ A name is lowercase letters, digits and `_`, and not `primary`; `binding:` is required.
540
+ The extra databases use the environment's adapter.
541
+ `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 (Sessions)".
542
+
284
543
  `database_id` is not a secret: it only names the database, and using it takes an API token of the account,
285
544
  so it is committed with `wrangler.toml` as Cloudflare's own examples do.
286
545
  Secrets the app needs go in Worker secrets (`pnpm exec wrangler secret put NAME`),
@@ -363,9 +622,9 @@ Settings are Worker variables in `wrangler.toml`:
363
622
  | Variable | |
364
623
  |---|---|
365
624
  | `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` |
625
+ | `CORS_HEADERS` | request headers a preflight allows (default `Authorization, Content-Type`). Listed one by one, as `*` does not cover `Authorization`. `X-D1-Bookmark` is added when a database has `sessions: true` |
367
626
  | `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 |
627
+ | `CORS_EXPOSE_HEADERS` | response headers the page may read (`Access-Control-Expose-Headers`, e.g. `X-Total-Count`); none by default, except `X-D1-Bookmark` when a database has `sessions: true` (it is also merged into the app's own `Access-Control-Expose-Headers`) |
369
628
 
370
629
  - A preflight from an allowed origin is answered 204 with `Access-Control-Allow-Origin` (the request's origin),
371
630
  `Access-Control-Allow-Methods` (`GET, HEAD, POST, PUT, PATCH, DELETE`), `Access-Control-Allow-Headers` and `Access-Control-Max-Age`.
@@ -380,6 +639,7 @@ Serving the frontend from the same origin as the API avoids CORS altogether.
380
639
 
381
640
  ```sh
382
641
  bundle exec edgeway test [ARGS] # ARGS go to spinel_test-unit
642
+ bin/test [FILE...] # in a generated app: the same, all of test/ without FILE
383
643
  ```
384
644
 
385
645
  `edgeway test` is the only supported way to run an app's tests.
@@ -399,6 +659,7 @@ bundle exec edgeway test [ARGS] # ARGS go to spinel_test-unit
399
659
 
400
660
  Each test runs against a real in-memory SQLite database (`Edgeway::DB::SQLiteAdapter`, through Spinel's FFI)
401
661
  with `db/migrations/*.sql` applied in file name order, so requests really write and read rows.
662
+ Each database under `databases:` (in any environment) gets its own, with `db/NAME/migrations`; `db(:analytics)` answers it, and `db` the primary.
402
663
 
403
664
  ### SQLite
404
665
 
@@ -0,0 +1,68 @@
1
+ // X-D1-Bookmark is "BINDING=bookmark, ..."; clients resend it whole.
2
+ import { addVary } from "./headers.js";
3
+
4
+ export const BOOKMARK_HEADER = "X-D1-Bookmark";
5
+
6
+ const isRead = (request) => request?.method === "GET" || request?.method === "HEAD";
7
+
8
+ // A bookmark is opaque and D1's: one that does not look like D1's is dropped,
9
+ // as D1 itself would keep a high-sorting garbage value as the latest bookmark.
10
+ const BINDING_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
11
+ const BOOKMARK = /^[0-9a-f-]{1,128}$/;
12
+
13
+ export function parseBookmarks(value) {
14
+ const bookmarks = {};
15
+ for (const pair of (value ?? "").split(",")) {
16
+ const separator = pair.indexOf("=");
17
+ const name = pair.slice(0, separator).trim();
18
+ const bookmark = pair.slice(separator + 1).trim();
19
+ if (separator > 0 && BINDING_NAME.test(name) && BOOKMARK.test(bookmark)) bookmarks[name] = bookmark;
20
+ }
21
+ return bookmarks;
22
+ }
23
+
24
+ export class D1Sessions {
25
+ constructor(env, request, names = []) {
26
+ this.env = env;
27
+ this.names = new Set(names);
28
+ this.readRequest = isRead(request);
29
+ this.bookmarks = parseBookmarks(request?.headers.get(BOOKMARK_HEADER));
30
+ this.sessions = new Map();
31
+ }
32
+
33
+ uses(name) {
34
+ return this.names.has(name);
35
+ }
36
+
37
+ database(name) {
38
+ const binding = this.env[name];
39
+ if (!binding || !this.names.has(name)) return binding;
40
+ if (!this.sessions.has(name)) this.sessions.set(name, this.start(binding, name));
41
+ return this.sessions.get(name);
42
+ }
43
+
44
+ start(binding, name) {
45
+ return binding.withSession(this.startPoint(name));
46
+ }
47
+
48
+ startPoint(name) {
49
+ if (!this.readRequest) return "first-primary";
50
+ return this.bookmarks[name] ?? "first-unconstrained";
51
+ }
52
+
53
+ header() {
54
+ const pairs = [];
55
+ for (const name of this.names) {
56
+ const bookmark = this.sessions.get(name)?.getBookmark() ?? this.bookmarks[name];
57
+ if (bookmark) pairs.push(`${name}=${bookmark}`);
58
+ }
59
+ return pairs.join(", ");
60
+ }
61
+
62
+ annotate(response) {
63
+ const value = this.header();
64
+ if (value) response.headers.set(BOOKMARK_HEADER, value);
65
+ if (this.names.size > 0) addVary(response.headers, BOOKMARK_HEADER);
66
+ return response;
67
+ }
68
+ }