reylo-effect 0.0.0-stage → 0.5.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,96 @@
1
+ # Changelog
2
+
3
+ ## 0.5.0 — 2026-10-26
4
+
5
+ ### Breaking
6
+
7
+ - `reylo-effect` depends on `effect` alone. The compiler host — the Node loader, the Vite and esbuild plugins, the TypeScript content mapper, `.reylo.yaml` resolution, PGlite, and YAML — is the new package `reylo-effect-tooling` (same version). Keep importing `reylo-effect/register`, `reylo-effect/vite`, `reylo-effect/esbuild`, and the `reylo-effect` content mapper: they forward to it and, when it is missing, fail naming `npm install -D reylo-effect-tooling@0.5.0`. Install it wherever `.sql` is compiled, including production `register` deployments.
8
+ - The `@reylosql/sqlite`, `@reylosql/postgres`, and `@reylosql/mysql` engines are optional peers of `reylo-effect-tooling`, no longer installed for you: install your dialect's engine at exactly 0.5.0 (`npm install -D @reylosql/sqlite@0.5.0`). A missing or mismatched engine names the package and version.
9
+ - `@effect/sql-pg`, `@effect/sql-mysql2`, `@effect/sql-sqlite-node`, `pg`, and `mysql2` are no longer dependencies. Install the Effect SQL adapter and driver your application executes against, for example `npm install @effect/sql-sqlite-node@4.0.6` or `npm install @effect/sql-pg@4.0.6 pg`.
10
+
11
+ ### Added
12
+
13
+ - Cache-only analysis: `REYLO_OFFLINE=1` in the environment or `offline: true` in `.reylo.yaml` (the environment wins; `REYLO_OFFLINE=0` turns it off). The session never opens an analysis connection and answers from `.reylo/` alone; a statement missing there fails its import with a diagnostic naming the file. Use it for production `node --import reylo-effect/register` deployments that ship `.reylo/` analyzed in CI.
14
+ - `query.pages(input, { pageSize })`: explicit limit/offset paging, on every driver.
15
+ - `query.cursor(input)`: one execution streamed through the driver's native statement stream (`@effect/sql-pg`, `@effect/sql-mysql2`), never retried or cached.
16
+ - `ReadRetry`: the read retry policy as a service. Provide `{ times, schedule }`, or `false` to run reads once and compose `Effect.retry` yourself.
17
+ - `query.withCache(store | false)`: a result cache attached to one query, independent of the ambient `QueryCache`; `false` never caches.
18
+ - The runtime dependency boundary is enforced: the `runtime` and `d1` builds fail on any import beyond `effect` (and `@effect/sql-d1` for `d1`), and tests check the build's metafile, both package manifests, and that every `src/` file is classified (`scripts/boundary.ts`).
19
+
20
+ ### Deprecated
21
+
22
+ - `query.stream(input, { chunkSize })`: use `pages` (its `pageSize` is `chunkSize`) or `cursor`.
23
+
24
+ ### Changed
25
+
26
+ - Tooling errors (`ReyloError`, `LexError`, `SchemaLoadError`, `SqlReadError`, `AnalysisClosed`, `UnsettledAnalysis`, and internal ones) are `Schema.TaggedError` classes; their `_tag`s are unchanged.
27
+ - The content-mapper RPC envelope, D1 proxy messages, and `.reylo/` entry envelopes are decoded with Schema rather than cast; a malformed frame or entry is ignored.
28
+ - Native JSON parameter descriptions use `effect/Cache` (bounded, deduplicating concurrent calls, never keeping connection failures) instead of a hand-rolled map.
29
+ - Compiler operations and `migrate` run in named spans (`Effect.fn("…")`, `Effect.withSpan`).
30
+ - Closing the Vite analysis owner no longer reports the interrupted file-event flush as a `REYLO_WATCH_ERROR` warning.
31
+ - Contract lowering builds a structured Schema IR (`src/schema-ir.ts`) that is printed once at emission; repeated-predicate and native JSON checks compare schemas structurally rather than as text. Generated modules and contract hashes are unchanged.
32
+
33
+ ### Fixed
34
+
35
+ - A `.sql` module that fails to compile under `reylo-effect/register` reports its file and diagnostics instead of an empty object.
36
+ - Flaky Vite watch tests on macOS: fixtures use canonical temp paths and wait for the watcher to settle, so late file-system events for setup writes no longer reload modules mid-test.
37
+
38
+ ### Documentation
39
+
40
+ - `SPEC.md` absorbs the Effect-native architecture: deployment modes, the runtime and tooling packages, cache-only production loading, and the runtime composition APIs. The emission target is `effect-sql/5`.
41
+
42
+ ## 0.4.1 — 2026-10-24 to 2026-10-26
43
+
44
+ ### Changed
45
+
46
+ - Hosts the published Reylo engine protocol directly (`src/reylo-engine.ts`, `src/reylo-session.ts`, `src/sqlite-drivers.ts`, `src/rpc.ts`) and no longer depends on the `reylo` npm package. `@reylosql/sqlite`, `@reylosql/postgres`, and `@reylosql/mysql` move from 0.3.0 to 0.5.0, and an engine of another version is refused.
47
+
48
+ ### Added
49
+
50
+ - Repeated PostgreSQL and SQLite predicates: `/* @repeat name:group AND|OR */` with `/* @case ... */` definitions generate a tagged-record input, each case checked against the schema.
51
+ - Native PostgreSQL JSON-source arguments: `/* @pg-native-json ... */` and `/* @pg-in ... */` keep JSON values in native parameter slots, checked by schema-file analysis.
52
+
53
+ ### Fixed
54
+
55
+ - Integration and review fixes across the content mapper, Vite plugin and owner, emission, and analysis sessions.
56
+
57
+ ## 0.4.0 — 2026-10-24
58
+
59
+ ### Changed
60
+
61
+ - Idiomatic Effect refactor: the analyzer, schema-file sessions, and the Vite owner are Effect programs on one process-wide `ManagedRuntime` (`dist/host.js`) holding the Node platform services and the `EngineLock` semaphore.
62
+ - `migrate(directory)` reads files through Effect's `FileSystem` and `Path` services; provide `NodeServices.layer`.
63
+ - Analysis and Vite performance optimizations.
64
+
65
+ ### Added
66
+
67
+ - `QueryCacheLayer({ capacity, timeToLive })`, a layer providing `QueryCache`.
68
+
69
+ ## 0.3.0 — 2026-10-24
70
+
71
+ ### Added
72
+
73
+ - `batch` from `reylo-effect/d1`: atomic D1 requests from `query.batch(input)` calls, decoded as a typed tuple by each query's cardinality.
74
+ - `query.stream(input, { chunkSize })` pages `many` reads; `QueryCacheStore` memoizes reads outside transactions.
75
+
76
+ ### Fixed
77
+
78
+ - Review fixes; Node.js 24 or newer is required.
79
+
80
+ ## 0.2.1 — 2026-10-24
81
+
82
+ ### Fixed
83
+
84
+ - `:payload::json` and `:events::rows(...)` bind one JSON text value on every dialect (`jsonb_to_recordset` on Postgres, `JSON_TABLE` on MySQL).
85
+ - PostgreSQL `bytea` fields inside structured parameters use `PostgresBytea`.
86
+
87
+ ## 0.2.0 — 2026-10-23
88
+
89
+ ### Added
90
+
91
+ - `reylo-effect/vite`: compiles `.sql` imports in Vite 8 for Node servers, SSR, and (with `serverEnvironments`) Cloudflare Worker environments; `nodeEnvironments` is its deprecated name.
92
+ - Cloudflare Workers support: `reylo-effect/runtime` uses no Node built-ins; `integers: number` types D1 and Durable Object SQLite projects.
93
+
94
+ ## 0.1.0 — 2026-10-22
95
+
96
+ - Initial import: `.sql` files as Effect queries checked against SQLite, Postgres, or MySQL with Reylo 0.3.0, a TypeScript content mapper, an esbuild plugin, the `node --import reylo-effect/register` loader, and `migrate`.
package/README.md CHANGED
@@ -1,3 +1,484 @@
1
- # Temporary Holding Version
1
+ # reylo-effect
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Import `.sql` files as [Effect](https://effect.website) queries checked against SQLite, Postgres, or MySQL.
4
+
5
+ SQL is analyzed during typechecking, bundling, or Node module loading and emitted as plain JavaScript. Queries use the caller's Effect SQL client and join its transactions; production bundles do not load the analyzer, and `reylo-effect/runtime` depends on `effect` alone.
6
+
7
+ [Quick start](#quick-start-sqlite) · [Project setup](#project) · [Queries](#queries) · [Vite](#vite) · [Workers](#cloudflare-workers)
8
+
9
+ ## Requirements
10
+
11
+ - Node.js 24+ for analysis, builds, and the Node loader.
12
+ - Effect and your SQL client on **4.0.6**. Keep one copy of `effect` so service tags match.
13
+ - Your Effect SQL adapter and database driver, installed by your application: `@effect/sql-sqlite-node`, `@effect/sql-pg` (with `pg`), `@effect/sql-mysql2` (with `mysql2`), or `@effect/sql-d1`. Since 0.5.0 `reylo-effect` no longer installs them.
14
+ - Vite **8.x** if using the Vite plugin.
15
+ - Where `.sql` is compiled (development, CI, builds, and `register` deployments): `reylo-effect-tooling` **0.5.0** and your dialect's engine, `@reylosql/sqlite`, `@reylosql/postgres`, or `@reylosql/mysql` at exactly **0.5.0**.
16
+
17
+ `reylo-effect` itself depends only on `effect`. The compiler host — the Node loader, the Vite and esbuild plugins, the content mapper, PGlite, and YAML — is the separate `reylo-effect-tooling` package; `reylo-effect/register`, `reylo-effect/vite`, `reylo-effect/esbuild`, and the `reylo-effect` content mapper forward to it, and name it when it is missing. Generated queries never load it. Generated queries also run on [Cloudflare Workers](#cloudflare-workers). See [SPEC.md](SPEC.md) for the full contract.
18
+
19
+ ## Quick start (SQLite)
20
+
21
+ Install the package and the matching Effect client in a Node.js 24+ project:
22
+
23
+ ```sh
24
+ npm install reylo-effect effect@4.0.6 @effect/platform-node@4.0.6 @effect/sql-sqlite-node@4.0.6
25
+ npm install -D reylo-effect-tooling@0.5.0 @reylosql/sqlite@0.5.0
26
+ ```
27
+
28
+ A deployment that only runs bundled output needs the first line alone; `node --import reylo-effect/register` in production also needs the tooling and engine at runtime, so install them as dependencies there.
29
+
30
+ Create `migrations/1_create_accounts.sql`:
31
+
32
+ ```sql
33
+ create table accounts (
34
+ id integer primary key,
35
+ name text not null
36
+ );
37
+ ```
38
+
39
+ Create `.reylo.yaml`. Analysis uses the schema file, so `dev.db` does not need to exist yet:
40
+
41
+ ```yaml
42
+ schema:
43
+ - migrations/1_create_accounts.sql
44
+ direct_url: file:./dev.db
45
+ ```
46
+
47
+ Create `find_account.sql`. Named parameters become properties of the query's input:
48
+
49
+ ```sql
50
+ select id, name from accounts where id = :id
51
+ ```
52
+
53
+ Create `app.ts`:
54
+
55
+ ```ts
56
+ import * as NodeServices from "@effect/platform-node/NodeServices"
57
+ import { SqliteClient } from "@effect/sql-sqlite-node"
58
+ import { Effect, Layer } from "effect"
59
+ import { migrate } from "reylo-effect"
60
+ import findAccount from "./find_account.sql"
61
+
62
+ const DatabaseLive = SqliteClient.layer({ filename: "./dev.db" })
63
+
64
+ const program = Effect.gen(function* () {
65
+ yield* migrate("./migrations")
66
+ const account = yield* findAccount({ id: 1n })
67
+ console.log(account) // Option.none() until an account with this id exists
68
+ })
69
+
70
+ await Effect.runPromise(
71
+ program.pipe(Effect.provide(Layer.mergeAll(DatabaseLive, NodeServices.layer))),
72
+ )
73
+ ```
74
+
75
+ Run it from the project directory:
76
+
77
+ ```sh
78
+ node --import reylo-effect/register app.ts
79
+ ```
80
+
81
+ Schema-file analysis does not apply migrations to the application database; the explicit `migrate` call does. SQLite integers default to `bigint`, hence `1n`. For TypeScript checking and production bundles, see [Project](#project) and [Vite](#vite). For D1's number-based contract, see [Cloudflare Workers](#cloudflare-workers).
82
+
83
+ ## Project
84
+
85
+ ### Analysis database
86
+
87
+ Set `direct_url` in `.reylo.yaml` to the database used for analysis:
88
+
89
+ | Dialect | Example `direct_url` |
90
+ | --- | --- |
91
+ | SQLite | `file:./dev.db` |
92
+ | Postgres | `postgres://localhost/app` |
93
+ | MySQL | `mysql://user:pass@localhost:3306/app` |
94
+
95
+ For SQLite or Postgres, add `schema` files or globs to analyze without a live database. The URL still selects the dialect but is not opened. MySQL requires a live MySQL 8.4+ server and does not support `schema` files.
96
+
97
+ ```yaml
98
+ schema:
99
+ - schema.sql
100
+ direct_url: postgres://localhost/app
101
+ ```
102
+
103
+ Analysis results are cached in `.reylo/` and refreshed when SQL or schema changes. Analysis never migrates the application database.
104
+
105
+ ### Migrations
106
+
107
+ `migrate(directory)` from `reylo-effect` applies pending `N_name.sql` files in numeric order and records them in `reylo_migrations`. Provide the SQL client and `NodeServices.layer`, as in the quick start.
108
+
109
+ The migrator manages transactions: omit `BEGIN`, `COMMIT`, and `ROLLBACK` from migration files. Savepoints are allowed. See [migration semantics](SPEC.md#7-transactions-and-migrations) for details.
110
+
111
+ ### TypeScript
112
+
113
+ Register the content mapper in `tsconfig.json`:
114
+
115
+ ```json
116
+ {
117
+ "compilerOptions": {
118
+ "strict": true,
119
+ "module": "nodenext",
120
+ "moduleResolution": "nodenext"
121
+ },
122
+ "contentMappers": [
123
+ { "package": "reylo-effect", "extensions": [".sql"] }
124
+ ]
125
+ }
126
+ ```
127
+
128
+ ```sh
129
+ tsc --runExternalCode
130
+ ```
131
+
132
+ Use a TypeScript compiler with content-mapper support (this repository uses `@effect/tsgo` via `effect-tsgo patch --typescript`). Settings belong in `.reylo.yaml`; `contentMappers[].options` is not supported. Bundlers do not run this typecheck for you.
133
+
134
+ ### Node loader
135
+
136
+ For unbundled applications, analyze `.sql` imports as they load:
137
+
138
+ ```sh
139
+ node --import reylo-effect/register app.ts
140
+ ```
141
+
142
+ The loader is a supported production deployment mode: no build or materialization step, and the emitted modules use the same runtime contract as bundled output. The process then also contains the compiler: the dialect's engine must be installed, an import can include analysis time (engine load, cache lookup), Postgres schema files need PGlite, and live analysis needs network access to the analysis database.
143
+
144
+ In production, run the loader **cache-only** so it never contacts a database for analysis:
145
+
146
+ 1. In CI, analyze against a dedicated analysis database (typecheck, build, or tests), which populates `.reylo/`.
147
+ 2. Ship `.reylo/` with the deployment.
148
+ 3. Start the process with `REYLO_OFFLINE=1` (or set `offline: true` in `.reylo.yaml`):
149
+
150
+ ```sh
151
+ REYLO_OFFLINE=1 node --import reylo-effect/register app.ts
152
+ ```
153
+
154
+ Cache-only analysis opens no connection, not even to read the schema fingerprint, and answers from `.reylo/` alone; a statement missing there fails its import with a diagnostic naming the file. `REYLO_OFFLINE` overrides the file, so `REYLO_OFFLINE=0` turns it off in CI. Without cache-only mode, the loader analyzes against `direct_url`, else `DATABASE_URL` — usually the production database — on every cold import. If you want live analysis in production, point `direct_url` at a dedicated analysis database.
155
+
156
+ ### esbuild
157
+
158
+ Replace `.sql` imports at build time (also supported by Bun builds using this plugin shape):
159
+
160
+ ```ts
161
+ import reyloEffect from "reylo-effect/esbuild"
162
+ import * as esbuild from "esbuild"
163
+
164
+ await esbuild.build({
165
+ entryPoints: ["src/app.ts"],
166
+ bundle: true,
167
+ platform: "node",
168
+ outfile: "dist/app.js",
169
+ plugins: [reyloEffect()],
170
+ })
171
+ ```
172
+
173
+ ## Vite
174
+
175
+ Use `reylo-effect/vite` with Vite 8 for server-side SQL imports:
176
+
177
+ ```ts
178
+ import reyloEffect from "reylo-effect/vite"
179
+ import { defineConfig } from "vite"
180
+
181
+ export default defineConfig({
182
+ plugins: [reyloEffect()],
183
+ build: { ssr: "src/server.ts" },
184
+ })
185
+ ```
186
+
187
+ The default environment is `ssr`. Use `serverEnvironments` to replace it with selected Node or Worker environments:
188
+
189
+ ```ts
190
+ plugins: [reyloEffect({ serverEnvironments: ["ssr", "api"] })]
191
+ ```
192
+
193
+ Names must be unique, nonempty, and configured as server environments. Bare `.sql` imports fail in browser code and unselected environments. `.sql?raw` and `.sql?url` remain assets; other user query modes are unsupported. `nodeEnvironments` is a deprecated alias; do not set both options.
194
+
195
+ - **Development:** use `ssrLoadModule` or the environment's `runner.import`, including in middleware mode. Query, schema-file, and config changes trigger recompilation, including new schema-glob matches.
196
+ - **Live databases:** SQLite schema changes are watched. Restart Vite after Postgres or MySQL DDL; those servers are not polled.
197
+ - **Production:** `vite build --ssr` emits JavaScript requiring only the runtime packages (`effect`, `reylo-effect`, and your client), not the loader, SQL sources, config, schema files, or analysis cache. Build watch also recompiles on changes.
198
+
199
+ If a dependency contains bare `.sql` imports, opt it into bundling:
200
+
201
+ ```ts
202
+ export default defineConfig({
203
+ plugins: [reyloEffect()],
204
+ ssr: { noExternal: ["sql-pkg"] },
205
+ })
206
+ ```
207
+
208
+ For custom environments, use their `resolve.noExternal`. Run `tsc --runExternalCode` separately to check query types.
209
+
210
+ ## Cloudflare Workers
211
+
212
+ Analysis runs in Node at build time; generated queries use the Node-free `reylo-effect/runtime`. Use that entry for runtime helpers in Workers and provide a compatible client:
213
+
214
+ | Database | Client | Notes |
215
+ | --- | --- | --- |
216
+ | D1 | `@effect/sql-d1` 4.0.6, `D1Client.layer({ db: env.DB })` | Set `integers: number`. No `nodejs_compat`. D1 has no interactive transactions, so `withTransaction` dies. |
217
+ | Durable Object SQLite | `@effect/sql-sqlite-do` 4.0.6 | Set `integers: number`. |
218
+ | Postgres via Hyperdrive | `@effect/sql-pg`, `PgClient.layer({ url: Redacted.make(env.HYPERDRIVE.connectionString) })` | Needs `nodejs_compat`. |
219
+ | MySQL via Hyperdrive | `@effect/sql-mysql2` | Needs `nodejs_compat`. Not covered by tests. |
220
+
221
+ D1 and Durable Object SQLite return INTEGER as a number and reject bigint parameters. Type those projects with the number contract:
222
+
223
+ ```yaml
224
+ schema:
225
+ - schema.sql
226
+ direct_url: file:./dev.db
227
+ integers: number
228
+ ```
229
+
230
+ Pass `{ id: 1 }`, not `{ id: 1n }`. Integers outside JavaScript's safe range fail decoding; BLOBs decode to `Uint8Array`. This setting is SQLite-only. To analyze Wrangler's local D1 database instead, point `direct_url` at its SQLite file under `.wrangler/state`.
231
+
232
+ ### D1 batches
233
+
234
+ Install `@effect/sql-d1@4.0.6` and use `batch` for an atomic request. `.batch(input)` queues a typed call without executing it:
235
+
236
+ ```ts
237
+ import { D1Client } from "@effect/sql-d1"
238
+ import { Effect } from "effect"
239
+ import { batch } from "reylo-effect/d1"
240
+ import insertAccount from "./insert_account.sql"
241
+ import findAccount from "./find_account.sql"
242
+
243
+ const program = batch([
244
+ insertAccount.batch({ id: 1, name: "Ada" }),
245
+ findAccount.batch({ id: 1 }),
246
+ ])
247
+
248
+ // A typed tuple: [void, Option<Account>] for these query cardinalities.
249
+ const [inserted, account] = await Effect.runPromise(
250
+ program.pipe(Effect.provide(D1Client.layer({ db: env.DB }))),
251
+ )
252
+ ```
253
+
254
+ - Requires SQLite queries with `integers: number`; other contracts fail with `D1BatchError`.
255
+ - Inputs must be known and encoded before submission. Statements run in order; SQL failures roll back the batch.
256
+ - Results use each query's cardinality and codecs. **Decoding happens after commit**: schema or cardinality failures cannot roll back writes.
257
+ - No retries or query caching. Invalidate cached reads after writes. Drift checks run before the batch, outside its transaction.
258
+ - D1 does not support interactive `withTransaction`; use SQL for dependencies between batch statements.
259
+
260
+ ### Building for Workers
261
+
262
+ With Vite, select the Worker's environment (its name with dashes replaced by underscores):
263
+
264
+ ```ts
265
+ import { cloudflare } from "@cloudflare/vite-plugin"
266
+ import reyloEffect from "reylo-effect/vite"
267
+ import { defineConfig } from "vite"
268
+
269
+ export default defineConfig({
270
+ plugins: [cloudflare(), reyloEffect({ serverEnvironments: ["my_worker"] })],
271
+ })
272
+ ```
273
+
274
+ `vite dev` recompiles edited queries in workerd. Use `?raw` when you want SQL text rather than a query.
275
+
276
+ Wrangler's bundler does not accept plugins. Bundle with `reylo-effect/esbuild` first, then deploy the output with `no_bundle = true`. You can automate this through Wrangler's `[build] command`:
277
+
278
+ ```ts
279
+ await esbuild.build({
280
+ entryPoints: ["src/worker.ts"],
281
+ bundle: true,
282
+ format: "esm",
283
+ platform: "neutral",
284
+ conditions: ["workerd", "worker", "browser"],
285
+ mainFields: ["module", "main"],
286
+ outfile: "dist/worker.js",
287
+ plugins: [reyloEffect()],
288
+ })
289
+ ```
290
+
291
+ ## Queries
292
+
293
+ ```ts
294
+ import { SqliteClient } from "@effect/sql-sqlite-node"
295
+ import { Config, Effect } from "effect"
296
+ import findUser from "./find_user.sql"
297
+
298
+ const DatabaseLive = SqliteClient.layerConfig({
299
+ filename: Config.String("DATABASE_PATH"),
300
+ })
301
+
302
+ const program = findUser({ id: 1n }).pipe(Effect.provide(DatabaseLive))
303
+ ```
304
+
305
+ For Postgres or MySQL, provide `PgClient.layerConfig({ url: Config.Redacted("DATABASE_URL") })` or `MysqlClient.layerConfig({ url: Config.Redacted("DATABASE_URL") })` instead.
306
+
307
+ Use named SQL parameters such as `:id` for input properties. Each module exports a default query function plus `Input` and `Row` schemas. Queries without parameters take `{}`. The analyzer infers the result cardinality:
308
+
309
+ | Cardinality | Result | Check |
310
+ | --- | --- | --- |
311
+ | `many` | `ReadonlyArray<Row>` | Decode every row |
312
+ | `maybeOne` | `Option<Row>` | At most one row |
313
+ | `one` | `Row` | Exactly one row |
314
+ | `none` | `void` | Discard the result, whatever the affected-row count |
315
+
316
+ Nullable columns remain nullable inside `Option`. Unexpected row counts fail with `CardinalityError`; invalid values fail with `SchemaError`. Driver `SqlError`s pass through unchanged.
317
+
318
+ ### Values and parameters
319
+
320
+ - **SQLite:** `INTEGER` defaults to `bigint` (`1n`); use `integers: number` for safe-integer numbers. `REAL` is a number.
321
+ - **Postgres:** `int2`/`int4` are numbers, `int8` is bigint, `date` is `YYYY-MM-DD` text, and `time` is microseconds as bigint.
322
+ - **MySQL:** `bigint` results are numbers when safe, otherwise decimal strings; inputs bind as decimal strings. Date/timestamp values are `Date`.
323
+ - Cast ambiguous parameters, e.g. `:id::integer` or `:flag::boolean`. JSON and row-set parameters use `:payload::json` and `:events::rows(...)`.
324
+ - MySQL `::row`/`::rows` inserts with optional fields are unsupported; use an explicit INSERT column list.
325
+
326
+ See the [value contract](SPEC.md#5-value-contract) for complete mappings, JSON behavior, and date/boolean edge cases.
327
+
328
+ ### Streaming and codecs
329
+
330
+ Two ways to stream a `many` read:
331
+
332
+ - `query.pages(input, { pageSize })` runs one statement per page with a generated limit/offset (default page size: 64). It works on every driver, including SQLite and D1. Use an explicit `ORDER BY` for predictable paging; each page is its own statement, so concurrent writes can shift rows between pages.
333
+ - `query.cursor(input)` runs the statement once and streams the driver's rows (`Statement.stream`): one snapshot, the SQL as written. It needs a driver with native cursors (`@effect/sql-pg`, `@effect/sql-mysql2`); `@effect/sql-sqlite-node` and `@effect/sql-d1` do not implement it. It is never retried or cached.
334
+
335
+ Returning mutations, including mutations in a CTE, execute once and stream their rows. Other cardinalities execute once and retain their row-count checks. Streams bypass the query cache. `query.stream(input, { chunkSize })` is the deprecated name of `pages`.
336
+
337
+ `query.withCodecs({ input, row })` keeps the SQL and cardinality and replaces either schema or both. Calls, streams, and batches retain input encoding and row decoding service requirements.
338
+
339
+ ### Schema drift
340
+
341
+ Drift checking is off by default. Enable `CheckSchemaDrift` to re-read the recorded catalog snapshot before execution, including on cache hits:
342
+
343
+ ```ts
344
+ import { CheckSchemaDrift } from "reylo-effect/runtime"
345
+
346
+ const checked = findUser({ id: 1n }).pipe(
347
+ Effect.provideService(CheckSchemaDrift, true),
348
+ )
349
+ ```
350
+
351
+ A mismatch fails with `SchemaDriftError`. This checks the catalog, not whether cached row data is still current.
352
+
353
+ ### Query cache
354
+
355
+ Compose `QueryCacheLayer` beside the client layer to memoize successful non-streaming reads outside transactions:
356
+
357
+ ```ts
358
+ import { Layer } from "effect"
359
+ import { QueryCacheLayer } from "reylo-effect/runtime"
360
+
361
+ const ServicesLive = Layer.mergeAll(
362
+ DatabaseLive,
363
+ QueryCacheLayer({ capacity: 1024, timeToLive: "30 seconds" }),
364
+ )
365
+
366
+ const cached = findUser({ id: 1n }).pipe(Effect.provide(ServicesLive))
367
+ ```
368
+
369
+ Reuse the provided layer in a shared application scope to share its cache across calls. Alternatively, create a store with `QueryCacheStore.make({ capacity, timeToLive })` and provide it as `QueryCache`, or attach it to one query explicitly:
370
+
371
+ ```ts
372
+ const store = yield* QueryCacheStore.make({ timeToLive: "1 minute" })
373
+ const cachedFindUser = findUser.withCache(store) // this store, whatever QueryCache provides
374
+ const uncachedFindUser = findUser.withCache(false) // never cached
375
+ ```
376
+
377
+ - Defaults: 1024 entries, with no time-based expiry.
378
+ - Concurrent identical calls share one database execution; entries are isolated by client and query contract/codecs.
379
+ - Writes, streams, and all queries inside transactions bypass the cache. Writes **do not invalidate** cached reads; run `store.invalidateAll` after mutations they depend on.
380
+ - SQL failures are not retained. Decoding and cardinality checks run for every caller, even on cache hits.
381
+
382
+ ### Retries and transactions
383
+
384
+ A retryable `SqlError` from a read outside a transaction is retried three more times, 5 milliseconds apart. Writes and statements inside a transaction run once. Schema and cardinality failures are not retried.
385
+
386
+ The policy is the `ReadRetry` service. Provide your own, or `false` to run every read once and compose `Effect.retry` yourself:
387
+
388
+ ```ts
389
+ import { ReadRetry } from "reylo-effect/runtime"
390
+
391
+ findUser({ id: 1n }).pipe(Effect.provideService(ReadRetry, { times: 5, schedule: Schedule.exponential("10 millis") }))
392
+ findUser({ id: 1n }).pipe(Effect.retry({ times: 2 }), Effect.provideService(ReadRetry, false))
393
+ ```
394
+
395
+ `retry_reads: false` in `.reylo.yaml` turns the built-in retry off for a project's generated queries.
396
+
397
+ Generated queries disable name transforms and set integer handling for their contract without changing your other queries.
398
+
399
+ Obtain `sql` from `SqlClient` inside an Effect program and wrap related queries in its transaction:
400
+
401
+ ```ts
402
+ import * as SqlClient from "effect/sql/SqlClient"
403
+
404
+ // Inside Effect.gen:
405
+ const sql = yield* SqlClient.SqlClient
406
+
407
+ yield* sql.withTransaction(Effect.gen(function* () {
408
+ yield* insertAccount({ id: 1n, name: "Ada" })
409
+ return yield* findUser({ id: 1n })
410
+ }))
411
+ ```
412
+
413
+ Decoding and cardinality failures roll back writes only inside a transaction. Connection lifetime and timeouts remain the SQL client's responsibility.
414
+
415
+ ## Development
416
+
417
+ From this repository:
418
+
419
+ ```sh
420
+ npm install
421
+ npm run build
422
+ npm test
423
+ npm run typecheck
424
+ npm run lint
425
+ npm run fmt
426
+ npm run fmt:check
427
+ ```
428
+
429
+ Installation patches TypeScript to use `@effect/tsgo` and links the `tooling/` workspace (`reylo-effect-tooling`). One source tree builds both packages: `dist/` for `reylo-effect` and `tooling/dist/` for `reylo-effect-tooling`; publish both at the same version. Tests and typechecking build first. `scripts/boundary.ts` classifies every source file; the build fails if the runtime reaches anything but `effect`. Linting runs Oxlint and Effect diagnostics; formatting uses Oxfmt.
430
+
431
+ SQLite and Postgres schema-file tests run locally. For live integration tests, set `REYLO_TEST_PG` and/or `REYLO_TEST_MYSQL` to test database URLs containing an `accounts` table with an `id` column. MySQL requires 8.4+. Worker tests use Miniflare; Hyperdrive tests also require a password in `REYLO_TEST_PG`.
432
+
433
+ ### Repeated PostgreSQL and SQLite predicates
434
+
435
+ A SQL module can declare repeated, tagged boolean expressions without accepting SQL text from callers:
436
+
437
+ ```sql
438
+ select id from items where /* @repeat filters:predicates AND */;
439
+ /* @case predicates.label */ label = :value /* @endcase */
440
+ /* @case predicates.minimum */ score >= :value /* @endcase */
441
+ ```
442
+
443
+ The generated input contains a `filters` array of tagged records, such as
444
+ `{ _tag: "label", value: "Ada" }` or `{ _tag: "minimum", value: 2 }`.
445
+ Parameters inside a case belong to that record; root parameters belong to the
446
+ query input. Cases can contain another `@repeat name:group OR` or `AND` directive.
447
+ Empty AND lists are true; empty OR lists are false. Groups must be finite and
448
+ acyclic, and definitions must follow the single query as shown above.
449
+
450
+ Every case is checked against the schema in its query context, including boolean
451
+ composition and empty lists. Scalar parameter types must agree across uses.
452
+ SQL requiring native structural parameter lowering is refused in repeated modules.
453
+ Every analyzed parameter must originate from an authored `:name` binding;
454
+ untracked positional parameter syntax is rejected.
455
+ SQLite programs bind positional `?` parameters, including native D1 batches; PostgreSQL
456
+ programs bind numbered parameters. MySQL repeated programs are not supported.
457
+ The runtime only expands authored, checked cases and binds encoded values; it never
458
+ accepts caller-supplied SQL. Replacement codecs must still encode the original
459
+ input contract. Query caches distinguish expanded SQL shapes, and streams use the
460
+ actual expanded binding count. Other dialects currently refuse these directives.
461
+
462
+ ### Native PostgreSQL JSON-source arguments
463
+
464
+ Explicit declarations preserve an existing JSON-to-native-driver boundary without
465
+ changing ordinary `Text` inputs:
466
+
467
+ ```sql
468
+ /* @pg-native-json title description */
469
+ INSERT INTO products(id,title,description) VALUES(:id,:title,:description)
470
+ ```
471
+
472
+ Declared fields generate `NativePgJsonArgument`: missing/undefined, null, boolean,
473
+ number, string, or recursively nested JSON arrays/records. Values retain their
474
+ native parameter slots, including nonfinite JSON numeric overflow; they are not
475
+ stringified. This does not guarantee every value executes successfully: original
476
+ driver inference errors and checked server-rejected argument families still fail.
477
+
478
+ For independent native `IN` slots, declare `/* @pg-native-json handles[] */` and
479
+ use `/* @pg-in handle :handles */` in the predicate. Empty lists become `1=0`.
480
+ Schema-file PostgreSQL analysis checks the finite native OID families and result
481
+ contracts. Only direct TEXT setters in a single-row INSERT/UPDATE, or a plain
482
+ column SELECT with an isolated IN predicate, are supported. Expressions,
483
+ aggregates, additional rows, boolean repeats and unsupported contexts fail
484
+ compilation. The runtime never accepts SQL clauses or identifiers from callers.