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.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +278 -0
  3. package/guidance/effect-first-development.md +1247 -0
  4. package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
  5. package/guidance/post__parse-dont-validate.md +109 -0
  6. package/guidance/progressive-disclosure-guidance.md +38 -0
  7. package/package.json +63 -0
  8. package/patterns/avoid-any.md +37 -0
  9. package/patterns/avoid-data-tagged-error.md +34 -0
  10. package/patterns/avoid-direct-json.md +51 -0
  11. package/patterns/avoid-direct-tag-checks.md +54 -0
  12. package/patterns/avoid-expect-in-if.md +52 -0
  13. package/patterns/avoid-mutable-state.md +70 -0
  14. package/patterns/avoid-native-fetch.md +61 -0
  15. package/patterns/avoid-node-imports.md +86 -0
  16. package/patterns/avoid-non-null-assertion.md +44 -0
  17. package/patterns/avoid-object-type.md +46 -0
  18. package/patterns/avoid-option-getorthrow.md +39 -0
  19. package/patterns/avoid-platform-coupling.md +43 -0
  20. package/patterns/avoid-process-env.md +43 -0
  21. package/patterns/avoid-react-hooks.md +73 -0
  22. package/patterns/avoid-schema-suffix.md +45 -0
  23. package/patterns/avoid-sync-fs.md +68 -0
  24. package/patterns/avoid-try-catch.md +47 -0
  25. package/patterns/avoid-ts-ignore.md +38 -0
  26. package/patterns/avoid-untagged-errors.md +67 -0
  27. package/patterns/avoid-yield-ref.md +46 -0
  28. package/patterns/casting-awareness.md +46 -0
  29. package/patterns/context-tag-extends.md +84 -0
  30. package/patterns/effect-catchall-default.md +61 -0
  31. package/patterns/effect-promise-vs-trypromise.md +47 -0
  32. package/patterns/effect-run-in-body.md +58 -0
  33. package/patterns/imperative-loops.md +76 -0
  34. package/patterns/prefer-arr-sort.md +52 -0
  35. package/patterns/prefer-duration-values.md +56 -0
  36. package/patterns/prefer-effect-fn.md +161 -0
  37. package/patterns/prefer-match-over-switch.md +48 -0
  38. package/patterns/prefer-option-over-null.md +56 -0
  39. package/patterns/prefer-redacted-config.md +70 -0
  40. package/patterns/prefer-schema-class.md +54 -0
  41. package/patterns/require-effect-concurrency.md +83 -0
  42. package/patterns/stream-large-files.md +63 -0
  43. package/patterns/throw-in-effect-gen.md +62 -0
  44. package/patterns/use-clock-service.md +45 -0
  45. package/patterns/use-command-executor-service.md +54 -0
  46. package/patterns/use-console-service.md +54 -0
  47. package/patterns/use-filesystem-service.md +59 -0
  48. package/patterns/use-http-client-service.md +77 -0
  49. package/patterns/use-path-service.md +53 -0
  50. package/patterns/use-random-service.md +45 -0
  51. package/patterns/use-temp-file-scoped.md +66 -0
  52. package/patterns/vm-in-wrong-file.md +51 -0
  53. package/patterns/yield-in-for-loop.md +61 -0
  54. package/skills/effect-ai-chat/SKILL.md +472 -0
  55. package/skills/effect-ai-language-model/SKILL.md +652 -0
  56. package/skills/effect-ai-prompt/SKILL.md +752 -0
  57. package/skills/effect-ai-provider/SKILL.md +668 -0
  58. package/skills/effect-ai-streaming/SKILL.md +418 -0
  59. package/skills/effect-ai-tool/SKILL.md +1132 -0
  60. package/skills/effect-atom-rpc/SKILL.md +488 -0
  61. package/skills/effect-atom-state/SKILL.md +640 -0
  62. package/skills/effect-batching/SKILL.md +614 -0
  63. package/skills/effect-cache/SKILL.md +570 -0
  64. package/skills/effect-cli/SKILL.md +523 -0
  65. package/skills/effect-command-executor/SKILL.md +675 -0
  66. package/skills/effect-concurrency-testing/SKILL.md +612 -0
  67. package/skills/effect-config/SKILL.md +580 -0
  68. package/skills/effect-context-witness/SKILL.md +274 -0
  69. package/skills/effect-domain-modeling/SKILL.md +1212 -0
  70. package/skills/effect-domain-predicates/SKILL.md +867 -0
  71. package/skills/effect-error-handling/SKILL.md +1581 -0
  72. package/skills/effect-fiber/SKILL.md +731 -0
  73. package/skills/effect-filesystem/SKILL.md +624 -0
  74. package/skills/effect-graph/SKILL.md +571 -0
  75. package/skills/effect-http-api/SKILL.md +1760 -0
  76. package/skills/effect-http-client/SKILL.md +989 -0
  77. package/skills/effect-http-server/SKILL.md +920 -0
  78. package/skills/effect-incremental-migration/SKILL.md +362 -0
  79. package/skills/effect-layer-design/SKILL.md +642 -0
  80. package/skills/effect-managed-runtime/SKILL.md +395 -0
  81. package/skills/effect-mcp-server/SKILL.md +608 -0
  82. package/skills/effect-observability/SKILL.md +719 -0
  83. package/skills/effect-optics/SKILL.md +554 -0
  84. package/skills/effect-parallelization/SKILL.md +668 -0
  85. package/skills/effect-path/SKILL.md +296 -0
  86. package/skills/effect-pattern-matching/SKILL.md +914 -0
  87. package/skills/effect-platform-abstraction/SKILL.md +1175 -0
  88. package/skills/effect-platform-layers/SKILL.md +514 -0
  89. package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
  90. package/skills/effect-react-composition/SKILL.md +986 -0
  91. package/skills/effect-react-vm/SKILL.md +675 -0
  92. package/skills/effect-rpc-api/SKILL.md +624 -0
  93. package/skills/effect-rpc-client/SKILL.md +666 -0
  94. package/skills/effect-rpc-cluster/SKILL.md +1623 -0
  95. package/skills/effect-rpc-server/SKILL.md +767 -0
  96. package/skills/effect-scheduling/SKILL.md +124 -0
  97. package/skills/effect-schema-composition/SKILL.md +975 -0
  98. package/skills/effect-schema-v4/SKILL.md +691 -0
  99. package/skills/effect-scope/SKILL.md +682 -0
  100. package/skills/effect-service-implementation/SKILL.md +656 -0
  101. package/skills/effect-socket/SKILL.md +703 -0
  102. package/skills/effect-sql/SKILL.md +781 -0
  103. package/skills/effect-stream/SKILL.md +765 -0
  104. package/skills/effect-testing/SKILL.md +1331 -0
  105. package/skills/effect-typeclass-design/SKILL.md +161 -0
  106. package/skills/effect-wide-events/Article.md +66 -0
  107. package/skills/effect-wide-events/SKILL.md +95 -0
  108. package/skills/effect-workflow/SKILL.md +810 -0
  109. package/src/agent-policy.ts +22 -0
  110. package/src/enforcer.ts +104 -0
  111. package/src/frontmatter.ts +34 -0
  112. package/src/guidance.ts +66 -0
  113. package/src/index.ts +38 -0
  114. package/src/pattern-catalog.ts +115 -0
  115. package/src/pattern-matcher.ts +178 -0
  116. package/src/pattern.ts +97 -0
  117. package/src/skills.ts +29 -0
  118. 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.