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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +270 -9
- data/js/cloudflare-d1-sessions.js +68 -0
- data/js/cloudflare.js +123 -22
- data/js/cors.js +15 -16
- data/js/headers.js +16 -0
- data/js/jwt.js +1 -1
- data/lib/edgeway/application.rb +6 -3
- data/lib/edgeway/cli/build/app_config.rb +39 -0
- data/lib/edgeway/cli/build/app_files.rb +40 -0
- data/lib/edgeway/cli/build/database_config.rb +92 -0
- data/lib/edgeway/cli/build/view_compiler.rb +191 -0
- data/lib/edgeway/cli/build.rb +36 -11
- data/lib/edgeway/cli/generate.rb +174 -26
- data/lib/edgeway/cli/new.rb +1 -1
- data/lib/edgeway/cli/test.rb +4 -1
- data/lib/edgeway/cli.rb +5 -4
- data/lib/edgeway/controller/base.rb +12 -2
- data/lib/edgeway/controller/dispatcher.rb +11 -1
- data/lib/edgeway/controller/rendering.rb +24 -4
- data/lib/edgeway/db/adapter.rb +38 -0
- data/lib/edgeway/db/memory.rb +9 -2
- data/lib/edgeway/db/query_stats.rb +46 -0
- data/lib/edgeway/db/sqlite.rb +73 -9
- data/lib/edgeway/errors.rb +22 -0
- data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
- data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
- data/lib/edgeway/generators/app/templates/README.md.tt +6 -2
- data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
- data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
- data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
- data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
- data/lib/edgeway/generators/app/templates/config/database.yml +17 -0
- data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
- data/lib/edgeway/generators/app/templates/wrangler.toml.tt +7 -0
- data/lib/edgeway/http/auth/stub.rb +1 -3
- data/lib/edgeway/http/request.rb +2 -22
- data/lib/edgeway/inflector.rb +19 -0
- data/lib/edgeway/model/attributes.rb +93 -10
- data/lib/edgeway/model/base.rb +50 -5
- data/lib/edgeway/model/connection_registry.rb +25 -0
- data/lib/edgeway/model/macros.rb +5 -1
- data/lib/edgeway/model/persistence.rb +18 -11
- data/lib/edgeway/model/relation.rb +106 -56
- data/lib/edgeway/model/validations.rb +29 -23
- data/lib/edgeway/routing/route.rb +1 -6
- data/lib/edgeway/server.rb +12 -0
- data/lib/edgeway/service/cloudflare/d1.rb +39 -10
- data/lib/edgeway/test.rb +29 -8
- data/lib/edgeway/version.rb +1 -1
- data/lib/edgeway/view.rb +24 -0
- data/lib/edgeway.rb +3 -0
- metadata +17 -4
- data/lib/edgeway/cli/app_config.rb +0 -35
- data/lib/edgeway/cli/app_files.rb +0 -37
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9800b1c381a9b0eabd63071ed84bec91abb5ac85e972695bd552a5ba67aca46c
|
|
4
|
+
data.tar.gz: b04ff64800edf5baa49a556309691839d155fbfce3246210e49f775431afd0ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
`
|
|
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
|
+
}
|