opencode-effect-enforcer 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.
- package/LICENSE +21 -0
- package/README.md +278 -0
- package/guidance/effect-first-development.md +1247 -0
- package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
- package/guidance/post__parse-dont-validate.md +109 -0
- package/guidance/progressive-disclosure-guidance.md +38 -0
- package/package.json +63 -0
- package/patterns/avoid-any.md +37 -0
- package/patterns/avoid-data-tagged-error.md +34 -0
- package/patterns/avoid-direct-json.md +51 -0
- package/patterns/avoid-direct-tag-checks.md +54 -0
- package/patterns/avoid-expect-in-if.md +52 -0
- package/patterns/avoid-mutable-state.md +70 -0
- package/patterns/avoid-native-fetch.md +61 -0
- package/patterns/avoid-node-imports.md +86 -0
- package/patterns/avoid-non-null-assertion.md +44 -0
- package/patterns/avoid-object-type.md +46 -0
- package/patterns/avoid-option-getorthrow.md +39 -0
- package/patterns/avoid-platform-coupling.md +43 -0
- package/patterns/avoid-process-env.md +43 -0
- package/patterns/avoid-react-hooks.md +73 -0
- package/patterns/avoid-schema-suffix.md +45 -0
- package/patterns/avoid-sync-fs.md +68 -0
- package/patterns/avoid-try-catch.md +47 -0
- package/patterns/avoid-ts-ignore.md +38 -0
- package/patterns/avoid-untagged-errors.md +67 -0
- package/patterns/avoid-yield-ref.md +46 -0
- package/patterns/casting-awareness.md +46 -0
- package/patterns/context-tag-extends.md +84 -0
- package/patterns/effect-catchall-default.md +61 -0
- package/patterns/effect-promise-vs-trypromise.md +47 -0
- package/patterns/effect-run-in-body.md +58 -0
- package/patterns/imperative-loops.md +76 -0
- package/patterns/prefer-arr-sort.md +52 -0
- package/patterns/prefer-duration-values.md +56 -0
- package/patterns/prefer-effect-fn.md +161 -0
- package/patterns/prefer-match-over-switch.md +48 -0
- package/patterns/prefer-option-over-null.md +56 -0
- package/patterns/prefer-redacted-config.md +70 -0
- package/patterns/prefer-schema-class.md +54 -0
- package/patterns/require-effect-concurrency.md +83 -0
- package/patterns/stream-large-files.md +63 -0
- package/patterns/throw-in-effect-gen.md +62 -0
- package/patterns/use-clock-service.md +45 -0
- package/patterns/use-command-executor-service.md +54 -0
- package/patterns/use-console-service.md +54 -0
- package/patterns/use-filesystem-service.md +59 -0
- package/patterns/use-http-client-service.md +77 -0
- package/patterns/use-path-service.md +53 -0
- package/patterns/use-random-service.md +45 -0
- package/patterns/use-temp-file-scoped.md +66 -0
- package/patterns/vm-in-wrong-file.md +51 -0
- package/patterns/yield-in-for-loop.md +61 -0
- package/skills/effect-ai-chat/SKILL.md +472 -0
- package/skills/effect-ai-language-model/SKILL.md +652 -0
- package/skills/effect-ai-prompt/SKILL.md +752 -0
- package/skills/effect-ai-provider/SKILL.md +668 -0
- package/skills/effect-ai-streaming/SKILL.md +418 -0
- package/skills/effect-ai-tool/SKILL.md +1132 -0
- package/skills/effect-atom-rpc/SKILL.md +488 -0
- package/skills/effect-atom-state/SKILL.md +640 -0
- package/skills/effect-batching/SKILL.md +614 -0
- package/skills/effect-cache/SKILL.md +570 -0
- package/skills/effect-cli/SKILL.md +523 -0
- package/skills/effect-command-executor/SKILL.md +675 -0
- package/skills/effect-concurrency-testing/SKILL.md +612 -0
- package/skills/effect-config/SKILL.md +580 -0
- package/skills/effect-context-witness/SKILL.md +274 -0
- package/skills/effect-domain-modeling/SKILL.md +1212 -0
- package/skills/effect-domain-predicates/SKILL.md +867 -0
- package/skills/effect-error-handling/SKILL.md +1581 -0
- package/skills/effect-fiber/SKILL.md +731 -0
- package/skills/effect-filesystem/SKILL.md +624 -0
- package/skills/effect-graph/SKILL.md +571 -0
- package/skills/effect-http-api/SKILL.md +1760 -0
- package/skills/effect-http-client/SKILL.md +989 -0
- package/skills/effect-http-server/SKILL.md +920 -0
- package/skills/effect-incremental-migration/SKILL.md +362 -0
- package/skills/effect-layer-design/SKILL.md +642 -0
- package/skills/effect-managed-runtime/SKILL.md +395 -0
- package/skills/effect-mcp-server/SKILL.md +608 -0
- package/skills/effect-observability/SKILL.md +719 -0
- package/skills/effect-optics/SKILL.md +554 -0
- package/skills/effect-parallelization/SKILL.md +668 -0
- package/skills/effect-path/SKILL.md +296 -0
- package/skills/effect-pattern-matching/SKILL.md +914 -0
- package/skills/effect-platform-abstraction/SKILL.md +1175 -0
- package/skills/effect-platform-layers/SKILL.md +514 -0
- package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
- package/skills/effect-react-composition/SKILL.md +986 -0
- package/skills/effect-react-vm/SKILL.md +675 -0
- package/skills/effect-rpc-api/SKILL.md +624 -0
- package/skills/effect-rpc-client/SKILL.md +666 -0
- package/skills/effect-rpc-cluster/SKILL.md +1623 -0
- package/skills/effect-rpc-server/SKILL.md +767 -0
- package/skills/effect-scheduling/SKILL.md +124 -0
- package/skills/effect-schema-composition/SKILL.md +975 -0
- package/skills/effect-schema-v4/SKILL.md +691 -0
- package/skills/effect-scope/SKILL.md +682 -0
- package/skills/effect-service-implementation/SKILL.md +656 -0
- package/skills/effect-socket/SKILL.md +703 -0
- package/skills/effect-sql/SKILL.md +781 -0
- package/skills/effect-stream/SKILL.md +765 -0
- package/skills/effect-testing/SKILL.md +1331 -0
- package/skills/effect-typeclass-design/SKILL.md +161 -0
- package/skills/effect-wide-events/Article.md +66 -0
- package/skills/effect-wide-events/SKILL.md +95 -0
- package/skills/effect-workflow/SKILL.md +810 -0
- package/src/agent-policy.ts +22 -0
- package/src/enforcer.ts +104 -0
- package/src/frontmatter.ts +34 -0
- package/src/guidance.ts +66 -0
- package/src/index.ts +38 -0
- package/src/pattern-catalog.ts +115 -0
- package/src/pattern-matcher.ts +178 -0
- package/src/pattern.ts +97 -0
- package/src/skills.ts +29 -0
- package/src/write-projection.ts +66 -0
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: effect-sql
|
|
3
|
+
description: Type-safe SQL with Effect — SqlClient tagged-template queries, SqlSchema, SqlModel CRUD repositories, SqlResolver batching, and Migrator. Use when working with databases, writing queries, defining models, or setting up migrations.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are an Effect TypeScript expert specializing in type-safe SQL database access using the Effect SQL modules.
|
|
7
|
+
|
|
8
|
+
## Effect Source Reference
|
|
9
|
+
|
|
10
|
+
The Effect v4 source is available at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`.
|
|
11
|
+
Browse and read files there directly to look up APIs, types, and implementations.
|
|
12
|
+
|
|
13
|
+
Reference this for:
|
|
14
|
+
|
|
15
|
+
- `packages/effect/src/unstable/sql/` — Core SQL modules (SqlClient, SqlSchema, SqlModel, SqlResolver, Migrator, Statement)
|
|
16
|
+
- `packages/effect/src/unstable/schema/Model.ts` — Model class with variant schemas
|
|
17
|
+
- `packages/sql/pg/src/PgClient.ts` — PostgreSQL driver example
|
|
18
|
+
|
|
19
|
+
## Core Imports
|
|
20
|
+
|
|
21
|
+
All SQL modules live under the `effect/unstable/sql` path:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { SqlClient } from 'effect/unstable/sql/SqlClient';
|
|
25
|
+
import * as SqlSchema from 'effect/unstable/sql/SqlSchema';
|
|
26
|
+
import * as SqlModel from 'effect/unstable/sql/SqlModel';
|
|
27
|
+
import * as SqlResolver from 'effect/unstable/sql/SqlResolver';
|
|
28
|
+
import * as Migrator from 'effect/unstable/sql/Migrator';
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Alternatively, the barrel exports namespace modules:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { SqlClient, SqlSchema, SqlModel, SqlResolver, Migrator } from 'effect/unstable/sql';
|
|
35
|
+
// With the barrel, the service is SqlClient.SqlClient.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
For Model schemas (used with SqlModel):
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { Model } from 'effect/unstable/schema';
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## SqlClient — Tagged Template Queries
|
|
45
|
+
|
|
46
|
+
`SqlClient` is a service accessed via `yield* SqlClient`. It doubles as a tagged template literal function for building parameterized queries.
|
|
47
|
+
|
|
48
|
+
### Basic Queries
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { Effect } from 'effect';
|
|
52
|
+
import { SqlClient } from 'effect/unstable/sql/SqlClient';
|
|
53
|
+
|
|
54
|
+
const program = Effect.gen(function* () {
|
|
55
|
+
const sql = yield* SqlClient;
|
|
56
|
+
|
|
57
|
+
// SELECT — returns ReadonlyArray<Row>
|
|
58
|
+
const users = yield* sql`SELECT * FROM users`;
|
|
59
|
+
|
|
60
|
+
// Parameterized query — values are safely interpolated
|
|
61
|
+
const user = yield* sql`SELECT * FROM users WHERE id = ${userId}`;
|
|
62
|
+
|
|
63
|
+
// INSERT with sql.insert helper
|
|
64
|
+
yield* sql`INSERT INTO users ${sql.insert({ name: 'Alice', email: 'alice@example.com' })}`;
|
|
65
|
+
|
|
66
|
+
// INSERT multiple rows
|
|
67
|
+
yield* sql`INSERT INTO users ${sql.insert([
|
|
68
|
+
{ name: 'Alice', email: 'alice@example.com' },
|
|
69
|
+
{ name: 'Bob', email: 'bob@example.com' }
|
|
70
|
+
])}`;
|
|
71
|
+
|
|
72
|
+
// INSERT with RETURNING
|
|
73
|
+
const [inserted] =
|
|
74
|
+
yield* sql`INSERT INTO users ${sql.insert({ name: 'Alice' }).returning('*')}`;
|
|
75
|
+
|
|
76
|
+
// UPDATE with sql.update helper (second arg = columns to omit from SET)
|
|
77
|
+
yield* sql`UPDATE users SET ${sql.update(userData, ['id'])} WHERE id = ${userData.id}`;
|
|
78
|
+
|
|
79
|
+
// DELETE
|
|
80
|
+
yield* sql`DELETE FROM users WHERE id = ${userId}`;
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Statement Properties
|
|
85
|
+
|
|
86
|
+
Each tagged template expression produces a `Statement<A>` which is also an `Effect<ReadonlyArray<A>, SqlError>`. Statements expose additional accessors:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
const stmt = sql`SELECT * FROM users`;
|
|
90
|
+
|
|
91
|
+
// Execute as Effect (default) — returns ReadonlyArray<Row>
|
|
92
|
+
yield* stmt;
|
|
93
|
+
|
|
94
|
+
// Require the first row; fails with Cause.NoSuchElementError when empty
|
|
95
|
+
const first = yield* Effect.head(stmt);
|
|
96
|
+
|
|
97
|
+
// Stream results row by row (for large result sets)
|
|
98
|
+
const stream = stmt.stream; // Stream<Row, SqlError>
|
|
99
|
+
|
|
100
|
+
// Raw result without row transforms
|
|
101
|
+
yield* stmt.withoutTransform;
|
|
102
|
+
|
|
103
|
+
// Get raw result object
|
|
104
|
+
yield* stmt.raw;
|
|
105
|
+
|
|
106
|
+
// Get rows as arrays of values (no column names)
|
|
107
|
+
yield* stmt.values;
|
|
108
|
+
|
|
109
|
+
// Execute without prepared statement
|
|
110
|
+
yield* stmt.unprepared;
|
|
111
|
+
|
|
112
|
+
// Compile to [sqlString, params] without executing
|
|
113
|
+
const [sqlString, params] = stmt.compile();
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Identifiers, Literals, and Helpers
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
const sql = yield* SqlClient;
|
|
120
|
+
|
|
121
|
+
// Identifier (table/column name) — properly escaped
|
|
122
|
+
sql('users'); // => Identifier
|
|
123
|
+
sql`SELECT * FROM ${sql('users')}`;
|
|
124
|
+
|
|
125
|
+
// Literal SQL (unescaped — use with caution)
|
|
126
|
+
sql.literal('NOW()');
|
|
127
|
+
|
|
128
|
+
// Unsafe raw query
|
|
129
|
+
yield* sql.unsafe<User>('SELECT * FROM users WHERE id = $1', [userId]);
|
|
130
|
+
|
|
131
|
+
// IN clause
|
|
132
|
+
sql`SELECT * FROM users WHERE ${sql.in('id', [1, 2, 3])}`;
|
|
133
|
+
|
|
134
|
+
// AND / OR chains
|
|
135
|
+
sql`SELECT * FROM users WHERE ${sql.and([sql`name = ${'Alice'}`, sql`active = ${true}`])}`;
|
|
136
|
+
|
|
137
|
+
// CSV helper (for ORDER BY, GROUP BY)
|
|
138
|
+
sql`SELECT * FROM users ORDER BY ${sql.csv(['name', 'created_at'])}`;
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Transactions
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const sql = yield* SqlClient;
|
|
145
|
+
|
|
146
|
+
// Wrap any effect in a transaction — automatically handles BEGIN/COMMIT/ROLLBACK
|
|
147
|
+
yield*
|
|
148
|
+
sql.withTransaction(
|
|
149
|
+
Effect.gen(function* () {
|
|
150
|
+
yield* sql`INSERT INTO orders ${sql.insert(order)}`;
|
|
151
|
+
yield* sql`UPDATE inventory SET quantity = quantity - 1 WHERE id = ${itemId}`;
|
|
152
|
+
})
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Nested calls to withTransaction create SAVEPOINTs automatically
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Transaction context is attached to the active `SqlClient` service instance. Queries join a transaction only when they run with that same client; avoid mixing clients or manually reserved connections for one atomic unit of work.
|
|
159
|
+
|
|
160
|
+
A failed top-level `BEGIN` or nested `SAVEPOINT` is propagated as a typed `SqlError`; the wrapped effect does not run, and no rollback is attempted for the transaction or savepoint that never started. If a nested `SAVEPOINT` failure escapes the outer transaction body, the already-started outer transaction rolls back. Because the failure is typed, outer code may catch it and continue the transaction instead. Commit and rollback command failures are treated as defects.
|
|
161
|
+
|
|
162
|
+
### Dialect Branching
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const sql = yield* SqlClient
|
|
166
|
+
|
|
167
|
+
// Branch on database dialect
|
|
168
|
+
const result = sql.onDialectOrElse({
|
|
169
|
+
pg: () => sql`SELECT * FROM users LIMIT 10`,
|
|
170
|
+
mysql: () => sql`SELECT * FROM users LIMIT 10`,
|
|
171
|
+
sqlite: () => sql`SELECT * FROM users LIMIT 10`,
|
|
172
|
+
orElse: () => sql`SELECT TOP 10 * FROM users`
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// All dialects required (no orElse)
|
|
176
|
+
sql.onDialect({
|
|
177
|
+
pg: () => ...,
|
|
178
|
+
mysql: () => ...,
|
|
179
|
+
sqlite: () => ...,
|
|
180
|
+
mssql: () => ...,
|
|
181
|
+
clickhouse: () => ...
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## SqlSchema — Schema-Validated Queries
|
|
186
|
+
|
|
187
|
+
`SqlSchema` wraps SQL queries with Effect Schema encoding/decoding for type-safe request and result handling.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { Schema } from 'effect';
|
|
191
|
+
import { SqlClient } from 'effect/unstable/sql/SqlClient';
|
|
192
|
+
import * as SqlSchema from 'effect/unstable/sql/SqlSchema';
|
|
193
|
+
|
|
194
|
+
const sql = yield* SqlClient;
|
|
195
|
+
|
|
196
|
+
// findAll — returns Array<Res["Type"]>
|
|
197
|
+
const listUsers = SqlSchema.findAll({
|
|
198
|
+
Request: Schema.Void,
|
|
199
|
+
Result: User,
|
|
200
|
+
execute: () => sql`SELECT * FROM users`
|
|
201
|
+
});
|
|
202
|
+
const users = yield* listUsers(void 0);
|
|
203
|
+
|
|
204
|
+
// findOne — returns Res["Type"], fails with NoSuchElementError if empty
|
|
205
|
+
const getUserById = SqlSchema.findOne({
|
|
206
|
+
Request: Schema.Number,
|
|
207
|
+
Result: User,
|
|
208
|
+
execute: (id) => sql`SELECT * FROM users WHERE id = ${id}`
|
|
209
|
+
});
|
|
210
|
+
const user = yield* getUserById(42);
|
|
211
|
+
|
|
212
|
+
// findOneOption — returns Option<Res["Type"]>
|
|
213
|
+
const findUser = SqlSchema.findOneOption({
|
|
214
|
+
Request: Schema.String,
|
|
215
|
+
Result: User,
|
|
216
|
+
execute: (email) => sql`SELECT * FROM users WHERE email = ${email}`
|
|
217
|
+
});
|
|
218
|
+
const maybeUser = yield* findUser('alice@example.com');
|
|
219
|
+
|
|
220
|
+
// findNonEmpty — returns NonEmptyArray<Res["Type"]>, fails with NoSuchElementError if empty
|
|
221
|
+
const getActiveUsers = SqlSchema.findNonEmpty({
|
|
222
|
+
Request: Schema.Void,
|
|
223
|
+
Result: User,
|
|
224
|
+
execute: () => sql`SELECT * FROM users WHERE active = true`
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// void — executes query, discards result, validates request
|
|
228
|
+
const deleteUser = SqlSchema.void({
|
|
229
|
+
Request: Schema.Number,
|
|
230
|
+
execute: (id) => sql`DELETE FROM users WHERE id = ${id}`
|
|
231
|
+
});
|
|
232
|
+
yield* deleteUser(42);
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Model — Schema Variant Classes
|
|
236
|
+
|
|
237
|
+
The `Model` module provides a schema class system with built-in variants for database operations (`select`, `insert`, `update`) and JSON APIs (`json`, `jsonCreate`, `jsonUpdate`).
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
import { Schema } from 'effect';
|
|
241
|
+
import { Model } from 'effect/unstable/schema';
|
|
242
|
+
|
|
243
|
+
const UserId = Schema.Number.pipe(Schema.brand('UserId'));
|
|
244
|
+
|
|
245
|
+
class User extends Model.Class<User>('User')({
|
|
246
|
+
// DB-generated primary key usable by repositories: omitted from insert,
|
|
247
|
+
// but present in select/update/json so update/delete can address rows.
|
|
248
|
+
id: UserId.pipe(Model.FieldExcept(["insert"])),
|
|
249
|
+
|
|
250
|
+
// DB-generated read-only field: present in select/json only.
|
|
251
|
+
searchText: Model.GeneratedByDb(Schema.String),
|
|
252
|
+
|
|
253
|
+
// Regular field: present in all variants
|
|
254
|
+
name: Schema.String,
|
|
255
|
+
email: Schema.String,
|
|
256
|
+
|
|
257
|
+
// Sensitive: present in DB variants, excluded from JSON variants
|
|
258
|
+
passwordHash: Model.Sensitive(Schema.String),
|
|
259
|
+
|
|
260
|
+
// Timestamps with auto-generation
|
|
261
|
+
createdAt: Model.DateTimeInsertFromDate, // auto-set on insert
|
|
262
|
+
updatedAt: Model.DateTimeUpdateFromDate, // auto-set on insert and update
|
|
263
|
+
|
|
264
|
+
// Optional field (nullable in DB, optional key in JSON)
|
|
265
|
+
bio: Model.FieldOption(Schema.String)
|
|
266
|
+
}) {}
|
|
267
|
+
|
|
268
|
+
// Variant schemas are auto-generated:
|
|
269
|
+
User; // select schema — all fields
|
|
270
|
+
User.insert; // insert schema — without FieldExcept(["insert"]) and GeneratedByDb fields
|
|
271
|
+
User.update; // update schema — includes FieldExcept(["insert"]) IDs, excludes GeneratedByDb fields
|
|
272
|
+
User.json; // JSON API schema — without Sensitive fields
|
|
273
|
+
User.jsonCreate;
|
|
274
|
+
User.jsonUpdate;
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Model Field Helpers
|
|
278
|
+
|
|
279
|
+
| Helper | select | insert | update | json | Description |
|
|
280
|
+
| ----------------------------------- | -------- | ------ | ------ | -------- | ----------------------------------------------------- |
|
|
281
|
+
| `Model.GeneratedByDb(S)` | S | — | — | S | DB-generated read-only field |
|
|
282
|
+
| `S.pipe(Model.FieldExcept(["insert"]))` | S | — | S | S | DB-generated repository ID that updates must include |
|
|
283
|
+
| `Model.GeneratedByApp(S)` | S | S | S | S | App-generated, required everywhere |
|
|
284
|
+
| `Model.Sensitive(S)` | S | S | S | — | Excluded from JSON variants |
|
|
285
|
+
| `Model.FieldOption(S)` | Option | Option | Option | Option | Nullable/optional across all variants |
|
|
286
|
+
| `Model.DateTimeInsertFromDate` | DateTime | auto | — | DateTime | Timestamp set on insert |
|
|
287
|
+
| `Model.DateTimeUpdateFromDate` | DateTime | auto | auto | DateTime | Timestamp set on insert+update |
|
|
288
|
+
| `Model.Field({...})` | custom | custom | custom | custom | Per-variant field configuration |
|
|
289
|
+
|
|
290
|
+
Use `GeneratedByDb` only for fields that are truly read-only after selection, such as computed columns. For a database-generated primary key used by `SqlModel.makeRepository` or update calls, keep the key in the update variant with `FieldExcept(["insert"])` or an explicit `Model.Field({ select, update, json })` shape; upstream prose may lag, but the constructor and `SqlModel` tests require this distinction.
|
|
291
|
+
|
|
292
|
+
## SqlModel — CRUD Repository
|
|
293
|
+
|
|
294
|
+
`SqlModel.makeRepository` generates a complete CRUD interface from a Model class.
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
import * as SqlModel from 'effect/unstable/sql/SqlModel';
|
|
298
|
+
|
|
299
|
+
const UserRepo =
|
|
300
|
+
yield*
|
|
301
|
+
SqlModel.makeRepository(User, {
|
|
302
|
+
tableName: 'users',
|
|
303
|
+
spanPrefix: 'UserRepo',
|
|
304
|
+
idColumn: 'id'
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// insert — returns the inserted row (decoded via Model schema)
|
|
308
|
+
const user =
|
|
309
|
+
yield* UserRepo.insert({ name: 'Alice', email: 'alice@example.com' });
|
|
310
|
+
|
|
311
|
+
// insertVoid — insert without returning the row
|
|
312
|
+
yield* UserRepo.insertVoid({ name: 'Bob', email: 'bob@example.com' });
|
|
313
|
+
|
|
314
|
+
// update — returns the updated row
|
|
315
|
+
const updated = yield* UserRepo.update({ id: userId, name: 'Alice Updated' });
|
|
316
|
+
|
|
317
|
+
// updateVoid — update without returning the row
|
|
318
|
+
yield* UserRepo.updateVoid({ id: userId, name: 'Alice Updated' });
|
|
319
|
+
|
|
320
|
+
// findById — returns the row, fails with NoSuchElementError if not found
|
|
321
|
+
const found = yield* UserRepo.findById(userId);
|
|
322
|
+
|
|
323
|
+
// delete
|
|
324
|
+
yield* UserRepo.delete(userId);
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Batched Resolvers (CRUD)
|
|
328
|
+
|
|
329
|
+
`SqlModel.makeResolvers` creates `RequestResolver` values for the same insert, insert-void, find-by-id, and delete operations — ideal for solving N+1 problems while keeping single-request call sites.
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
import { RequestResolver } from 'effect';
|
|
333
|
+
import * as SqlModel from 'effect/unstable/sql/SqlModel';
|
|
334
|
+
import * as SqlResolver from 'effect/unstable/sql/SqlResolver';
|
|
335
|
+
|
|
336
|
+
const UserResolvers =
|
|
337
|
+
yield*
|
|
338
|
+
SqlModel.makeResolvers(User, {
|
|
339
|
+
tableName: 'users',
|
|
340
|
+
spanPrefix: 'UserResolver',
|
|
341
|
+
idColumn: 'id'
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const findById = SqlResolver.request(UserResolvers.findById);
|
|
345
|
+
const user = yield* findById(userId);
|
|
346
|
+
|
|
347
|
+
const inserted = yield* SqlResolver.request(
|
|
348
|
+
User.insert.make({ name: 'Alice', email: 'alice@example.com' }),
|
|
349
|
+
UserResolvers.insert
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
yield* SqlResolver.request(userId, UserResolvers.delete);
|
|
353
|
+
|
|
354
|
+
// Tune individual returned resolvers when you need a wider collection window or cap.
|
|
355
|
+
const cappedFindById = UserResolvers.findById.pipe(
|
|
356
|
+
RequestResolver.setDelay('50 millis'),
|
|
357
|
+
RequestResolver.batchN(100)
|
|
358
|
+
);
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Soft Deletes
|
|
362
|
+
|
|
363
|
+
`makeRepository` and `makeResolvers` accept `softDeleteColumn`. When supplied, reads and updates add an `is null` filter for that column, and delete updates the column to `CURRENT_TIMESTAMP` instead of removing the row.
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
class SoftDeleteUser extends Model.Class<SoftDeleteUser>('SoftDeleteUser')({
|
|
367
|
+
id: UserId.pipe(Model.FieldExcept(["insert"])),
|
|
368
|
+
name: Schema.String,
|
|
369
|
+
deletedAt: Schema.NullOr(Schema.String).pipe(
|
|
370
|
+
Model.FieldOnly(["select", "update"])
|
|
371
|
+
)
|
|
372
|
+
}) {}
|
|
373
|
+
|
|
374
|
+
const repo = yield* SqlModel.makeRepository(SoftDeleteUser, {
|
|
375
|
+
tableName: 'users',
|
|
376
|
+
spanPrefix: 'UserRepo',
|
|
377
|
+
idColumn: 'id',
|
|
378
|
+
softDeleteColumn: 'deletedAt'
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// findById/update ignore rows where deletedAt is not null.
|
|
382
|
+
yield* repo.delete(userId); // UPDATE users SET deletedAt = CURRENT_TIMESTAMP ...
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Resolver versions created by `SqlModel.makeResolvers` honor the same soft-delete filter and delete behavior.
|
|
386
|
+
|
|
387
|
+
## SqlResolver — Request Batching
|
|
388
|
+
|
|
389
|
+
`SqlResolver` creates `RequestResolver` instances for batching SQL queries. Use these when you need fine-grained control or custom query shapes beyond the resolvers returned by `SqlModel.makeResolvers`.
|
|
390
|
+
|
|
391
|
+
### Ordered Resolver
|
|
392
|
+
|
|
393
|
+
Results map 1:1 to requests by position. Result count must match request count.
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
import * as SqlResolver from 'effect/unstable/sql/SqlResolver';
|
|
397
|
+
|
|
398
|
+
const insertResolver = SqlResolver.ordered({
|
|
399
|
+
Request: User.insert,
|
|
400
|
+
Result: User,
|
|
401
|
+
execute: (requests) =>
|
|
402
|
+
sql`INSERT INTO users ${sql.insert(requests).returning('*')}`
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Use with SqlResolver.request
|
|
406
|
+
const insertUser = SqlResolver.request(insertResolver);
|
|
407
|
+
const user = yield* insertUser({ name: 'Alice', email: 'alice@example.com' });
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### FindById Resolver
|
|
411
|
+
|
|
412
|
+
Batches lookups by ID, matching results back by a key function.
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
const findByIdResolver = SqlResolver.findById({
|
|
416
|
+
Id: UserId,
|
|
417
|
+
Result: User,
|
|
418
|
+
ResultId: (user) => user.id,
|
|
419
|
+
execute: (ids) => sql`SELECT * FROM users WHERE ${sql.in('id', ids)}`
|
|
420
|
+
});
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### Grouped Resolver
|
|
424
|
+
|
|
425
|
+
Returns multiple results per request, grouped by a key.
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
const userPostsResolver = SqlResolver.grouped({
|
|
429
|
+
Request: UserId,
|
|
430
|
+
RequestGroupKey: (userId) => userId,
|
|
431
|
+
Result: Post,
|
|
432
|
+
ResultGroupKey: (post) => post.userId,
|
|
433
|
+
execute: (userIds) =>
|
|
434
|
+
sql`SELECT * FROM posts WHERE ${sql.in('user_id', userIds)}`
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// Returns NonEmptyArray<Post> per userId
|
|
438
|
+
const posts = yield* SqlResolver.request(userPostsResolver)(userId);
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### Void Resolver
|
|
442
|
+
|
|
443
|
+
For side-effect-only batched operations (deletes, updates without return).
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
const deleteResolver = SqlResolver.void({
|
|
447
|
+
Request: UserId,
|
|
448
|
+
execute: (ids) => sql`DELETE FROM users WHERE ${sql.in('id', ids)}`
|
|
449
|
+
});
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### Configuring Resolvers
|
|
453
|
+
|
|
454
|
+
Resolvers already batch same-turn/concurrently queued requests by default (`Effect.yieldNow`). Use `RequestResolver.setDelay` only to widen the collection window, and `RequestResolver.batchN` to cap batch size.
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
import { RequestResolver } from 'effect';
|
|
458
|
+
|
|
459
|
+
const resolver = SqlResolver.ordered({ ... }).pipe(
|
|
460
|
+
RequestResolver.setDelay('50 millis'), // wider collection window
|
|
461
|
+
RequestResolver.batchN(100), // max batch size
|
|
462
|
+
RequestResolver.withSpan('UserRepo.insert')
|
|
463
|
+
);
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
## Migrator — Schema Migrations
|
|
467
|
+
|
|
468
|
+
The `Migrator` module runs sequential, transactional migrations tracked in a `effect_sql_migrations` table.
|
|
469
|
+
|
|
470
|
+
### Migration File Convention
|
|
471
|
+
|
|
472
|
+
Files must be named `<id>_<name>.js`, `<id>_<name>.ts`, `<id>_<name>.mjs`, or `<id>_<name>.mts`, where `id` is a numeric identifier (e.g. `0001_create_users.ts`). Unsupported extensions are ignored by the file and glob loaders.
|
|
473
|
+
|
|
474
|
+
Each migration file exports a default Effect:
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
// migrations/0001_create_users.ts
|
|
478
|
+
import { Effect } from 'effect';
|
|
479
|
+
import { SqlClient } from 'effect/unstable/sql/SqlClient';
|
|
480
|
+
|
|
481
|
+
export default Effect.gen(function* () {
|
|
482
|
+
const sql = yield* SqlClient;
|
|
483
|
+
yield* sql`
|
|
484
|
+
CREATE TABLE users (
|
|
485
|
+
id SERIAL PRIMARY KEY,
|
|
486
|
+
name TEXT NOT NULL,
|
|
487
|
+
email TEXT NOT NULL UNIQUE,
|
|
488
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
489
|
+
)
|
|
490
|
+
`;
|
|
491
|
+
});
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
### Running Migrations
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
import * as Migrator from 'effect/unstable/sql/Migrator';
|
|
498
|
+
|
|
499
|
+
// Create a migrator (optionally with schema dump support)
|
|
500
|
+
const migrate = Migrator.make({
|
|
501
|
+
// Optional: dump schema after migrations
|
|
502
|
+
dumpSchema: (path, table) => Effect.void
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// Load migrations from filesystem
|
|
506
|
+
const completed =
|
|
507
|
+
yield*
|
|
508
|
+
migrate({
|
|
509
|
+
loader: Migrator.fromFileSystem('./migrations'),
|
|
510
|
+
schemaDirectory: './migrations', // optional: where to dump _schema.sql
|
|
511
|
+
table: 'effect_sql_migrations' // optional: custom table name (default)
|
|
512
|
+
});
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
### Migration Loaders
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
// From filesystem (requires both FileSystem and Path services)
|
|
519
|
+
Migrator.fromFileSystem('./migrations');
|
|
520
|
+
|
|
521
|
+
// From Vite/bundler glob import; only .js/.ts/.mjs/.mts keys are loaded
|
|
522
|
+
Migrator.fromGlob(import.meta.glob('./migrations/*.{js,ts,mjs,mts}'));
|
|
523
|
+
|
|
524
|
+
// From a record of effects (inline)
|
|
525
|
+
Migrator.fromRecord({
|
|
526
|
+
'0001_create_users': Effect.gen(function* () {
|
|
527
|
+
const sql = yield* SqlClient;
|
|
528
|
+
yield* sql`CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`;
|
|
529
|
+
}),
|
|
530
|
+
'0002_add_email': Effect.gen(function* () {
|
|
531
|
+
const sql = yield* SqlClient;
|
|
532
|
+
yield* sql`ALTER TABLE users ADD COLUMN email TEXT`;
|
|
533
|
+
})
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// From Babel-style glob (keys like _0001_createUsersTs or _0001_createUsersMts)
|
|
537
|
+
Migrator.fromBabelGlob(migrations);
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
`fromFileSystem` resolves dynamic imports through the platform `Path` service so absolute Windows paths become valid file URLs. Its loader requirement is `FileSystem | Path`; aggregate platform layers already provide both, but a standalone `FileSystem` layer must now be paired with the matching platform-aware `Path` layer (not a POSIX-only layer on Windows).
|
|
541
|
+
|
|
542
|
+
### Migration Errors
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
import * as Migrator from 'effect/unstable/sql/Migrator';
|
|
546
|
+
|
|
547
|
+
// MigrationError has a `kind` discriminator:
|
|
548
|
+
// - "BadState" — migrations table in unexpected state
|
|
549
|
+
// - "ImportError" — failed to import migration file
|
|
550
|
+
// - "Failed" — migration execution failed
|
|
551
|
+
// - "Duplicates" — duplicate migration IDs found
|
|
552
|
+
// - "Locked" — migrations already running (concurrent protection)
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
## Driver Packages and Layer Setup
|
|
556
|
+
|
|
557
|
+
Effect SQL uses driver-specific packages that provide `SqlClient` layers.
|
|
558
|
+
|
|
559
|
+
### Common Drivers
|
|
560
|
+
|
|
561
|
+
| Package | Database |
|
|
562
|
+
| ------------------------- | ------------------------------------ |
|
|
563
|
+
| `@effect/sql-pg` | PostgreSQL (via `pg`) |
|
|
564
|
+
| `@effect/sql-pglite` | Embedded PostgreSQL/PGlite |
|
|
565
|
+
| `@effect/sql-mysql2` | MySQL (via `mysql2`) |
|
|
566
|
+
| `@effect/sql-sqlite-node` | SQLite (via `better-sqlite3`) |
|
|
567
|
+
| `@effect/sql-libsql` | libSQL / Turso |
|
|
568
|
+
| `@effect/sql-mssql` | Microsoft SQL Server |
|
|
569
|
+
| `@effect/sql-clickhouse` | ClickHouse |
|
|
570
|
+
|
|
571
|
+
### PostgreSQL Setup
|
|
572
|
+
|
|
573
|
+
```ts
|
|
574
|
+
import { Effect, Layer } from 'effect';
|
|
575
|
+
import { PgClient } from '@effect/sql-pg';
|
|
576
|
+
import { SqlClient } from 'effect/unstable/sql/SqlClient';
|
|
577
|
+
|
|
578
|
+
// Static config
|
|
579
|
+
const DatabaseLayer = PgClient.layer({
|
|
580
|
+
host: 'localhost',
|
|
581
|
+
port: 5432,
|
|
582
|
+
database: 'myapp',
|
|
583
|
+
username: 'postgres',
|
|
584
|
+
password: Redacted.make('secret'),
|
|
585
|
+
// Optional settings:
|
|
586
|
+
maxConnections: 10,
|
|
587
|
+
idleTimeout: '30 seconds',
|
|
588
|
+
transformResultNames: (s) => camelCase(s), // snake_case → camelCase
|
|
589
|
+
transformQueryNames: (s) => snakeCase(s) // camelCase → snake_case
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// From Config (reads from environment/config provider)
|
|
593
|
+
const DatabaseLayerConfig = PgClient.layerConfig({
|
|
594
|
+
url: Config.redacted('DATABASE_URL')
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// The layer provides both PgClient and SqlClient services
|
|
598
|
+
const program = Effect.gen(function* () {
|
|
599
|
+
const sql = yield* SqlClient; // generic interface
|
|
600
|
+
// or
|
|
601
|
+
const pg = yield* PgClient; // pg-specific (has .json(), .listen(), .notify())
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const main = program.pipe(Effect.provide(DatabaseLayer));
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
### PgClient-Specific Features
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
const pg = yield* PgClient;
|
|
611
|
+
|
|
612
|
+
// JSON parameter helper
|
|
613
|
+
sql`INSERT INTO data ${sql.insert({ metadata: pg.json({ key: 'value' }) })}`;
|
|
614
|
+
|
|
615
|
+
// LISTEN/NOTIFY
|
|
616
|
+
const notifications = pg.listen('my_channel'); // Stream<string, SqlError>
|
|
617
|
+
yield* pg.notify('my_channel', 'hello');
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
### PGlite Setup
|
|
621
|
+
|
|
622
|
+
Use `@effect/sql-pglite` for embedded PostgreSQL-compatible databases backed by `@electric-sql/pglite`. Its layer provides both the PGlite-specific service and the generic `SqlClient` service.
|
|
623
|
+
|
|
624
|
+
```ts
|
|
625
|
+
import { Config, Effect } from 'effect';
|
|
626
|
+
import { PgliteClient, PgliteMigrator } from '@effect/sql-pglite';
|
|
627
|
+
import * as Migrator from 'effect/unstable/sql/Migrator';
|
|
628
|
+
import { SqlClient } from 'effect/unstable/sql/SqlClient';
|
|
629
|
+
|
|
630
|
+
const PgliteLayer = PgliteClient.layer({
|
|
631
|
+
dataDir: 'idb://myapp'
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
const PgliteLayerConfig = PgliteClient.layerConfig({
|
|
635
|
+
dataDir: Config.string('PGLITE_DATA_DIR')
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
const program = Effect.gen(function* () {
|
|
639
|
+
const sql = yield* SqlClient; // generic interface
|
|
640
|
+
const pglite = yield* PgliteClient.PgliteClient;
|
|
641
|
+
|
|
642
|
+
yield* sql`INSERT INTO data ${sql.insert({ metadata: pglite.json({ key: 'value' }) })}`;
|
|
643
|
+
const notifications = pglite.listen('my_channel');
|
|
644
|
+
yield* pglite.notify('my_channel', 'hello');
|
|
645
|
+
yield* pglite.refreshArrayTypes;
|
|
646
|
+
const snapshot = yield* pglite.dumpDataDir('gzip');
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
const runPgliteMigrations = PgliteMigrator.run({
|
|
650
|
+
loader: Migrator.fromFileSystem('./migrations')
|
|
651
|
+
});
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
`PgliteClient.layerFrom` wraps an existing acquired client. `PgliteMigrator` reuses the shared migrator loaders, but it does not currently write schema dumps for `schemaDirectory`; use PGlite data-dir persistence or `PgliteClient.dumpDataDir` for embedded snapshots.
|
|
655
|
+
|
|
656
|
+
### Connection Reservation
|
|
657
|
+
|
|
658
|
+
```ts
|
|
659
|
+
const sql = yield* SqlClient;
|
|
660
|
+
|
|
661
|
+
// Reserve a dedicated connection (useful for advisory locks, temp tables, etc.)
|
|
662
|
+
const conn = yield* sql.reserve; // Effect<Connection, SqlError, Scope>
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
## Streaming Large Result Sets
|
|
666
|
+
|
|
667
|
+
Use `.stream` on any statement for memory-efficient processing of large result sets:
|
|
668
|
+
|
|
669
|
+
```ts
|
|
670
|
+
import { Stream } from 'effect';
|
|
671
|
+
|
|
672
|
+
const sql = yield* SqlClient;
|
|
673
|
+
|
|
674
|
+
// Stream rows one at a time
|
|
675
|
+
const allUsers = sql`SELECT * FROM users`.stream;
|
|
676
|
+
|
|
677
|
+
// Process with Stream combinators
|
|
678
|
+
yield*
|
|
679
|
+
allUsers.pipe(
|
|
680
|
+
Stream.filter((user) => user.active),
|
|
681
|
+
Stream.map((user) => user.email),
|
|
682
|
+
Stream.runCollect
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
// Chunked streaming (driver-dependent, e.g. pg uses cursor with 128-row chunks)
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
## Error Handling
|
|
689
|
+
|
|
690
|
+
All SQL operations can fail with `SqlError`:
|
|
691
|
+
|
|
692
|
+
```ts
|
|
693
|
+
import { SqlError } from 'effect/unstable/sql/SqlError';
|
|
694
|
+
|
|
695
|
+
yield*
|
|
696
|
+
sql`SELECT * FROM users`.pipe(
|
|
697
|
+
Effect.catchTag('SqlError', (err) => {
|
|
698
|
+
console.error('SQL failed:', err.message);
|
|
699
|
+
console.error('Cause:', err.cause); // underlying driver error
|
|
700
|
+
return Effect.succeed([]);
|
|
701
|
+
})
|
|
702
|
+
);
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
Unique constraint failures classify as `err.reason._tag === 'UniqueViolation'` when the driver exposes enough detail. The `constraint` field names the violated constraint; classifiers fall back to `'unknown'` when the name is missing.
|
|
706
|
+
|
|
707
|
+
```ts
|
|
708
|
+
const constraintName = (err: SqlError) =>
|
|
709
|
+
err.reason._tag === 'UniqueViolation'
|
|
710
|
+
? err.reason.constraint || 'unknown'
|
|
711
|
+
: undefined;
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
Keep non-unique integrity failures on their own paths; they remain `ConstraintError` rather than `UniqueViolation`.
|
|
715
|
+
|
|
716
|
+
`SqlResolver` also exposes `ResultLengthMismatch` for ordered resolvers when result count doesn't match request count.
|
|
717
|
+
|
|
718
|
+
## Complete Example
|
|
719
|
+
|
|
720
|
+
```ts
|
|
721
|
+
import { Effect, Layer, Schema } from 'effect';
|
|
722
|
+
import { Model } from 'effect/unstable/schema';
|
|
723
|
+
import { SqlClient } from 'effect/unstable/sql/SqlClient';
|
|
724
|
+
import * as SqlModel from 'effect/unstable/sql/SqlModel';
|
|
725
|
+
import * as Migrator from 'effect/unstable/sql/Migrator';
|
|
726
|
+
import { PgClient } from '@effect/sql-pg';
|
|
727
|
+
|
|
728
|
+
// 1. Define Model
|
|
729
|
+
const UserId = Schema.Number.pipe(Schema.brand('UserId'));
|
|
730
|
+
|
|
731
|
+
class User extends Model.Class<User>('User')({
|
|
732
|
+
id: UserId.pipe(Model.FieldExcept(["insert"])),
|
|
733
|
+
name: Schema.String,
|
|
734
|
+
email: Schema.String,
|
|
735
|
+
createdAt: Model.DateTimeInsertFromDate,
|
|
736
|
+
updatedAt: Model.DateTimeUpdateFromDate
|
|
737
|
+
}) {}
|
|
738
|
+
|
|
739
|
+
// 2. Build Repository
|
|
740
|
+
const makeUserRepo = Effect.gen(function* () {
|
|
741
|
+
const repo = yield* SqlModel.makeRepository(User, {
|
|
742
|
+
tableName: 'users',
|
|
743
|
+
spanPrefix: 'UserRepo',
|
|
744
|
+
idColumn: 'id'
|
|
745
|
+
});
|
|
746
|
+
return repo;
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
// 3. Run Migrations
|
|
750
|
+
const runMigrations = Migrator.make({})({
|
|
751
|
+
loader: Migrator.fromFileSystem('./migrations')
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// 4. Wire it up
|
|
755
|
+
const DatabaseLayer = PgClient.layer({
|
|
756
|
+
host: 'localhost',
|
|
757
|
+
database: 'myapp',
|
|
758
|
+
username: 'postgres'
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
const program = Effect.gen(function* () {
|
|
762
|
+
yield* runMigrations;
|
|
763
|
+
const repo = yield* makeUserRepo;
|
|
764
|
+
const user = yield* repo.insert({
|
|
765
|
+
name: 'Alice',
|
|
766
|
+
email: 'alice@example.com'
|
|
767
|
+
});
|
|
768
|
+
const found = yield* repo.findById(user.id);
|
|
769
|
+
yield* Effect.log(`Created user: ${found.name}`);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
Effect.runPromise(program.pipe(Effect.provide(DatabaseLayer)));
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
## Anti-Patterns
|
|
776
|
+
|
|
777
|
+
- **String concatenation in queries** — Always use tagged template interpolation or `sql.unsafe()`. Never build SQL strings manually.
|
|
778
|
+
- **Forgetting `sql.insert()` / `sql.update()`** — Use the helpers for INSERT/UPDATE instead of manually listing columns and values.
|
|
779
|
+
- **Not using transactions** — Wrap multi-statement mutations in `sql.withTransaction()` for atomicity.
|
|
780
|
+
- **Ignoring `SqlSchema`** — Raw queries return untyped rows. Use `SqlSchema.findOne/findAll/void` for validated I/O.
|
|
781
|
+
- **Assuming custom delay is required for batching** — `SqlResolver` resolvers batch concurrently queued requests by default via `Effect.yieldNow`. Add `RequestResolver.setDelay` only to widen the collection window when the latency tradeoff is acceptable.
|