@thomasfosterau/effect-drizzle-ext 0.0.0 → 0.1.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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +508 -2
  3. package/dist/columns.d.ts +113 -0
  4. package/dist/columns.d.ts.map +1 -0
  5. package/dist/columns.js +146 -0
  6. package/dist/columns.js.map +1 -0
  7. package/dist/constraints.d.ts +117 -0
  8. package/dist/constraints.d.ts.map +1 -0
  9. package/dist/constraints.js +93 -0
  10. package/dist/constraints.js.map +1 -0
  11. package/dist/database.d.ts +189 -0
  12. package/dist/database.d.ts.map +1 -0
  13. package/dist/database.js +51 -0
  14. package/dist/database.js.map +1 -0
  15. package/dist/derive.d.ts +97 -0
  16. package/dist/derive.d.ts.map +1 -0
  17. package/dist/derive.js +142 -0
  18. package/dist/derive.js.map +1 -0
  19. package/dist/errors.d.ts +157 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +287 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/index-CFhgcF4N.d.ts +418 -0
  24. package/dist/index-CFhgcF4N.d.ts.map +1 -0
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.js +1 -0
  27. package/dist/jsonapi/derive.d.ts +28 -0
  28. package/dist/jsonapi/derive.d.ts.map +1 -0
  29. package/dist/jsonapi/derive.js +28 -0
  30. package/dist/jsonapi/derive.js.map +1 -0
  31. package/dist/jsonapi/index.d.ts +189 -0
  32. package/dist/jsonapi/index.d.ts.map +1 -0
  33. package/dist/jsonapi/index.js +164 -0
  34. package/dist/jsonapi/index.js.map +1 -0
  35. package/dist/query.d.ts +111 -0
  36. package/dist/query.d.ts.map +1 -0
  37. package/dist/query.js +142 -0
  38. package/dist/query.js.map +1 -0
  39. package/dist/references.d.ts +38 -0
  40. package/dist/references.d.ts.map +1 -0
  41. package/dist/references.js +60 -0
  42. package/dist/references.js.map +1 -0
  43. package/dist/sort.d.ts +129 -0
  44. package/dist/sort.d.ts.map +1 -0
  45. package/dist/sort.js +225 -0
  46. package/dist/sort.js.map +1 -0
  47. package/dist/src-CBeuhy5b.js +478 -0
  48. package/dist/src-CBeuhy5b.js.map +1 -0
  49. package/dist/update.d.ts +61 -0
  50. package/dist/update.d.ts.map +1 -0
  51. package/dist/update.js +64 -0
  52. package/dist/update.js.map +1 -0
  53. package/dist/versioned-chain.d.ts +524 -0
  54. package/dist/versioned-chain.d.ts.map +1 -0
  55. package/dist/versioned-chain.js +298 -0
  56. package/dist/versioned-chain.js.map +1 -0
  57. package/package.json +102 -4
  58. package/src/columns.ts +186 -0
  59. package/src/constraints.ts +225 -0
  60. package/src/database.ts +273 -0
  61. package/src/derivation-error.ts +27 -0
  62. package/src/derive.ts +320 -0
  63. package/src/errors.ts +466 -0
  64. package/src/index.ts +653 -0
  65. package/src/jsonapi/derive.ts +74 -0
  66. package/src/jsonapi/index.ts +355 -0
  67. package/src/query.ts +183 -0
  68. package/src/references.ts +103 -0
  69. package/src/sort.ts +368 -0
  70. package/src/tree.ts +367 -0
  71. package/src/update.ts +88 -0
  72. package/src/versioned-chain.ts +1027 -0
package/src/errors.ts ADDED
@@ -0,0 +1,466 @@
1
+ /**
2
+ * @copyright Thomas Foster
3
+ *
4
+ * Typed Postgres error classification.
5
+ *
6
+ * Effect already classifies SQLSTATE: `@effect/sql-pg` maps a driver error
7
+ * into `SqlError.reason` (`UniqueViolation{constraint}`, `ConstraintError`,
8
+ * `DeadlockError`, `SerializationError`, `ConnectionError`, …). It keeps the
9
+ * violated constraint for `23505` only — every other `23xxx` becomes a bare
10
+ * `ConstraintError` with no constraint / table / column — and drizzle then
11
+ * wraps the whole thing inside `EffectDrizzleQueryError.cause`, which is an
12
+ * Effect `Cause`, not the driver error. So the facts a caller needs to say
13
+ * *which* constraint failed are still in the graph, just not on the failure
14
+ * it is handed.
15
+ *
16
+ * This module recovers them: {@link findPgError} walks the error graph for the
17
+ * driver's Postgres error (the node carrying a SQLSTATE `code`; under
18
+ * `@effect/sql-pg` an `Error` carrying the server's ErrorResponse fields) and
19
+ * reads its `code` / `constraint` / `table` / `column` / `detail` / `schema`,
20
+ * and {@link classifyError} turns those facts into one of seven tagged errors
21
+ * a consumer can `catchTag`.
22
+ *
23
+ * Nothing here is thrown: these are runtime query failures and they travel in
24
+ * the Effect error channel. (Construction-time failures are the thrown
25
+ * `DerivationError` in `./derive`.)
26
+ *
27
+ * The module imports neither `drizzle-orm` nor `@effect/sql-pg`, at runtime or
28
+ * as types: it recognises those shapes structurally, so it works the same on a
29
+ * bare `SqlError`, a bare Postgres error and the drizzle-wrapped shape, and
30
+ * a consumer holding any of them classifies it without pulling a driver into
31
+ * the bundle.
32
+ *
33
+ * @module errors
34
+ */
35
+
36
+ import * as Array from "effect/Array";
37
+ import * as Effect from "effect/Effect";
38
+ import * as Option from "effect/Option";
39
+ import * as Predicate from "effect/Predicate";
40
+ import * as Record from "effect/Record";
41
+ import * as Schema from "effect/Schema";
42
+
43
+ // ── SQLSTATE vocabulary ────────────────────────────────────────────
44
+
45
+ /** SQLSTATE `23505` — unique (or primary key) constraint violated. */
46
+ const UNIQUE_VIOLATION = "23505";
47
+
48
+ /** SQLSTATE `23503` — foreign key constraint violated. */
49
+ const FOREIGN_KEY_VIOLATION = "23503";
50
+
51
+ /** SQLSTATE `23502` — NOT NULL constraint violated. */
52
+ const NOT_NULL_VIOLATION = "23502";
53
+
54
+ /** SQLSTATE `23514` — CHECK constraint violated. */
55
+ const CHECK_VIOLATION = "23514";
56
+
57
+ /** SQLSTATE `23P01` — EXCLUDE constraint violated. */
58
+ const EXCLUSION_VIOLATION = "23P01";
59
+
60
+ /** SQLSTATE `40001` — serialization failure; the transaction may be retried. */
61
+ const SERIALIZATION_FAILURE = "40001";
62
+
63
+ /** SQLSTATE `40P01` — deadlock detected; the transaction may be retried. */
64
+ const DEADLOCK_DETECTED = "40P01";
65
+
66
+ /**
67
+ * The stable Postgres message fragment for a unique violation, kept as a
68
+ * belt-and-suspenders fallback for {@link isUniqueViolation} (see its note).
69
+ */
70
+ const PG_UNIQUE_VIOLATION_MESSAGE = "duplicate key value violates unique constraint";
71
+
72
+ /**
73
+ * The shape of a Postgres SQLSTATE: five uppercase alphanumerics (`23505`,
74
+ * `23P01`, `40001`). This is what tells the driver error apart from a wrapper
75
+ * that carries a `code` of its own — an `SqlError` or a Node `ErrnoException`
76
+ * both have a `code`, neither looks like this — so the walk descends past
77
+ * them instead of stopping at the first `code` it meets.
78
+ */
79
+ const PG_SQLSTATE = /^[0-9A-Z]{5}$/;
80
+
81
+ /** How deep into the error graph to search for the driver error. */
82
+ const MAX_ERROR_DEPTH = 8;
83
+
84
+ /**
85
+ * An error node as the walk sees it: an arbitrary object read as a bag of
86
+ * properties. `Record` here is `effect/Record`'s namespace, so the built-in
87
+ * `Record<K, V>` utility type is shadowed; this alias is what the walk's
88
+ * signatures use in its place.
89
+ */
90
+ type ErrorNode = Record.ReadonlyRecord<string, unknown>;
91
+
92
+ // ── The facts a Postgres error carries ─────────────────────────────
93
+
94
+ /**
95
+ * The fields of a Postgres error response this library reads, as recovered from
96
+ * wherever the driver's error sits in an error graph. `code` is the SQLSTATE and
97
+ * is always present (finding it is what identifies the node); every other
98
+ * fact is `Option` because Postgres populates it per error class — a `23502`
99
+ * carries `column` and `table` but no `constraint`, a `23503` carries
100
+ * `constraint`, `table` and `detail`.
101
+ */
102
+ export interface PgErrorFacts {
103
+ /** The five-character SQLSTATE, e.g. `23505`. */
104
+ readonly code: string;
105
+ /** The driver's message, or `""` when the node carries none. */
106
+ readonly message: string;
107
+ /** The violated constraint's name, for the constraint classes that name one. */
108
+ readonly constraint: Option.Option<string>;
109
+ /** The table the statement touched. */
110
+ readonly table: Option.Option<string>;
111
+ /** The column at fault, populated for NOT NULL violations. */
112
+ readonly column: Option.Option<string>;
113
+ /** Postgres' `DETAIL` line, which names the conflicting key values. */
114
+ readonly detail: Option.Option<string>;
115
+ /** The schema the table lives in. */
116
+ readonly schema: Option.Option<string>;
117
+ }
118
+
119
+ /** Reads a string-valued property off an error node as an `Option`. */
120
+ const readString = (node: ErrorNode, key: string): Option.Option<string> => {
121
+ const value = node[key];
122
+ return Predicate.isString(value) ? Option.some(value) : Option.none();
123
+ };
124
+
125
+ /**
126
+ * The SQLSTATE a node declares, under either `code` (node-postgres) or
127
+ * `sqlState` (the field name other drivers and some wrappers use), and only
128
+ * when it actually looks like a SQLSTATE.
129
+ */
130
+ const sqlStateOf = (node: ErrorNode): Option.Option<string> =>
131
+ Option.filter(
132
+ Option.orElse(readString(node, "code"), () => readString(node, "sqlState")),
133
+ (code) => PG_SQLSTATE.test(code),
134
+ );
135
+
136
+ /** The driver error's facts, when this node *is* the driver error. */
137
+ const pgErrorFactsOf = (node: ErrorNode): Option.Option<PgErrorFacts> =>
138
+ Option.map(sqlStateOf(node), (code) => ({
139
+ code,
140
+ message: Option.getOrElse(readString(node, "message"), () => ""),
141
+ constraint: readString(node, "constraint"),
142
+ table: readString(node, "table"),
143
+ column: readString(node, "column"),
144
+ detail: readString(node, "detail"),
145
+ schema: readString(node, "schema"),
146
+ }));
147
+
148
+ // ── The bounded, cycle-guarded walk ────────────────────────────────
149
+
150
+ /** True when `error` is a fresh object worth descending into (within depth, not yet visited). */
151
+ const isSearchable = (error: unknown, depth: number, seen: Set<unknown>): error is ErrorNode =>
152
+ Predicate.isObjectOrArray(error) && depth < MAX_ERROR_DEPTH && !seen.has(error);
153
+
154
+ /**
155
+ * The child error nodes reachable from a node: its `.cause` (read explicitly,
156
+ * as it is often a NON-enumerable field on `SqlError` and on drizzle's
157
+ * `EffectDrizzleQueryError`) plus its enumerable values, with arrays flattened
158
+ * (an Effect `Cause`'s `reasons`).
159
+ */
160
+ const childErrorNodes = (node: ErrorNode): unknown[] => [
161
+ node["cause"],
162
+ ...Array.flatMap(Record.values(node), (value) => (Array.isArray(value) ? value : [value])),
163
+ ];
164
+
165
+ /**
166
+ * Bounded, cycle-guarded depth-first search of an error graph for the first
167
+ * node `match` accepts.
168
+ *
169
+ * A failed write reaches a caller deeply wrapped, and NOT on a simple `.cause`
170
+ * chain: drizzle's `EffectDrizzleQueryError` holds an Effect `Cause` under a
171
+ * (non-enumerable) `.cause`, and the driver's Postgres error carrying the SQLSTATE
172
+ * sits inside that Cause's `reasons`. Hence a search over the whole reachable
173
+ * graph — reading `.cause` explicitly (for the non-enumerable wrappers) and
174
+ * sweeping enumerable values / arrays (for the Cause `reasons`) — rather than
175
+ * a cause chain.
176
+ */
177
+ const searchErrorGraph = <A>(
178
+ cause: unknown,
179
+ match: (node: ErrorNode) => Option.Option<A>,
180
+ depth: number,
181
+ seen: Set<unknown>,
182
+ ): Option.Option<A> => {
183
+ if (!isSearchable(cause, depth, seen)) return Option.none();
184
+ seen.add(cause);
185
+ return Option.orElse(match(cause), () =>
186
+ Array.findFirst(childErrorNodes(cause), (child) =>
187
+ searchErrorGraph(child, match, depth + 1, seen),
188
+ ),
189
+ );
190
+ };
191
+
192
+ /**
193
+ * The driver's Postgres error facts, recovered from anywhere in `error`'s graph:
194
+ * a bare Postgres error, a bare `SqlError` (whose `reason` holds the driver
195
+ * error as its `cause`), or drizzle's `EffectDrizzleQueryError` wrapping an
196
+ * Effect `Cause` that holds the `SqlError`. `Option.none()` when the graph
197
+ * holds no node declaring a SQLSTATE, including for a non-object input.
198
+ *
199
+ * The walk is bounded (depth 8) and cycle-guarded, so a self-referential or
200
+ * pathologically deep graph terminates.
201
+ */
202
+ // oxlint-disable-next-line effect/no-unknown-parameters -- public entry over an opaque failure graph (any caught value); narrowing it would break callers
203
+ export const findPgError = (error: unknown): Option.Option<PgErrorFacts> =>
204
+ // The cycle guard needs *reference* identity across an arbitrary error graph:
205
+ // `HashSet`'s structural equality would mis-merge distinct-but-equal nodes and
206
+ // would hash (i.e. walk) cyclic values it is meant to stop us walking.
207
+ // oxlint-disable-next-line effect/avoid-native-object-helpers -- reference identity is the point
208
+ searchErrorGraph(error, pgErrorFactsOf, 0, new Set());
209
+
210
+ // ── The tagged classification ──────────────────────────────────────
211
+
212
+ /**
213
+ * SQLSTATE `23505`: a unique or primary key constraint was violated.
214
+ * `cause` is the value classification started from, so the wrapping graph
215
+ * (drizzle's query text and params, the `SqlError`) survives the recovery.
216
+ */
217
+ export class UniqueViolation extends Schema.TaggedError<UniqueViolation>()("UniqueViolation", {
218
+ constraint: Schema.Option(Schema.String),
219
+ table: Schema.Option(Schema.String),
220
+ detail: Schema.Option(Schema.String),
221
+ cause: Schema.Defect(),
222
+ }) {}
223
+
224
+ /** SQLSTATE `23503`: a foreign key constraint was violated. `cause` as in {@link UniqueViolation}. */
225
+ export class ForeignKeyViolation extends Schema.TaggedError<ForeignKeyViolation>()(
226
+ "ForeignKeyViolation",
227
+ {
228
+ constraint: Schema.Option(Schema.String),
229
+ table: Schema.Option(Schema.String),
230
+ detail: Schema.Option(Schema.String),
231
+ cause: Schema.Defect(),
232
+ },
233
+ ) {}
234
+
235
+ /** SQLSTATE `23502`: a NOT NULL constraint was violated. `column` names the offending column. */
236
+ export class NotNullViolation extends Schema.TaggedError<NotNullViolation>()("NotNullViolation", {
237
+ column: Schema.Option(Schema.String),
238
+ table: Schema.Option(Schema.String),
239
+ cause: Schema.Defect(),
240
+ }) {}
241
+
242
+ /** SQLSTATE `23514`: a CHECK constraint was violated — including the bounds checks `./derive` writes. */
243
+ export class CheckViolation extends Schema.TaggedError<CheckViolation>()("CheckViolation", {
244
+ constraint: Schema.Option(Schema.String),
245
+ table: Schema.Option(Schema.String),
246
+ cause: Schema.Defect(),
247
+ }) {}
248
+
249
+ /** SQLSTATE `23P01`: an EXCLUDE constraint was violated. */
250
+ export class ExclusionViolation extends Schema.TaggedError<ExclusionViolation>()(
251
+ "ExclusionViolation",
252
+ {
253
+ constraint: Schema.Option(Schema.String),
254
+ table: Schema.Option(Schema.String),
255
+ cause: Schema.Defect(),
256
+ },
257
+ ) {}
258
+
259
+ /**
260
+ * SQLSTATE `40001`: the transaction lost a serialization conflict. Retryable:
261
+ * the same transaction re-run from the top may commit.
262
+ */
263
+ export class SerializationFailure extends Schema.TaggedError<SerializationFailure>()(
264
+ "SerializationFailure",
265
+ { cause: Schema.Defect() },
266
+ ) {}
267
+
268
+ /**
269
+ * SQLSTATE `40P01`: the transaction was chosen as a deadlock victim.
270
+ * Retryable, like {@link SerializationFailure}.
271
+ */
272
+ export class DeadlockDetected extends Schema.TaggedError<DeadlockDetected>()("DeadlockDetected", {
273
+ cause: Schema.Defect(),
274
+ }) {}
275
+
276
+ /**
277
+ * The integrity-constraint family (`23xxx`): a failure the *statement* caused,
278
+ * which a caller can map to a domain conflict. Retrying it unchanged fails
279
+ * again.
280
+ */
281
+ export type ConstraintViolation =
282
+ | UniqueViolation
283
+ | ForeignKeyViolation
284
+ | NotNullViolation
285
+ | CheckViolation
286
+ | ExclusionViolation;
287
+
288
+ /**
289
+ * Every failure this module recognises: a {@link ConstraintViolation}, or one
290
+ * of the two retryable transaction failures.
291
+ */
292
+ export type ClassifiedError = ConstraintViolation | SerializationFailure | DeadlockDetected;
293
+
294
+ /** Builds the `23505` classification; shared by `classifyError` and `catchUniqueViolation`. */
295
+ const makeUniqueViolation = (facts: PgErrorFacts, cause: unknown): UniqueViolation =>
296
+ new UniqueViolation({
297
+ constraint: facts.constraint,
298
+ table: facts.table,
299
+ detail: facts.detail,
300
+ cause,
301
+ });
302
+
303
+ /**
304
+ * SQLSTATE → constraint-family constructor. A table rather than a `switch`:
305
+ * the SQLSTATE is the key the whole module dispatches on, and a lookup that
306
+ * misses is exactly the `Option.none()` an unclassified code should produce.
307
+ */
308
+ const CONSTRAINT_VIOLATION_BY_CODE: Record.ReadonlyRecord<
309
+ string,
310
+ (facts: PgErrorFacts, cause: unknown) => ConstraintViolation
311
+ > = {
312
+ [UNIQUE_VIOLATION]: makeUniqueViolation,
313
+ [FOREIGN_KEY_VIOLATION]: (facts, cause) =>
314
+ new ForeignKeyViolation({
315
+ constraint: facts.constraint,
316
+ table: facts.table,
317
+ detail: facts.detail,
318
+ cause,
319
+ }),
320
+ [NOT_NULL_VIOLATION]: (facts, cause) =>
321
+ new NotNullViolation({ column: facts.column, table: facts.table, cause }),
322
+ [CHECK_VIOLATION]: (facts, cause) =>
323
+ new CheckViolation({ constraint: facts.constraint, table: facts.table, cause }),
324
+ [EXCLUSION_VIOLATION]: (facts, cause) =>
325
+ new ExclusionViolation({ constraint: facts.constraint, table: facts.table, cause }),
326
+ };
327
+
328
+ /** SQLSTATE → retryable-transaction-failure constructor; a table, as above. */
329
+ const TRANSACTION_FAILURE_BY_CODE: Record.ReadonlyRecord<
330
+ string,
331
+ (cause: unknown) => SerializationFailure | DeadlockDetected
332
+ > = {
333
+ [SERIALIZATION_FAILURE]: (cause) => new SerializationFailure({ cause }),
334
+ [DEADLOCK_DETECTED]: (cause) => new DeadlockDetected({ cause }),
335
+ };
336
+
337
+ /** The constraint-family classification for these facts, if the SQLSTATE is one of the five. */
338
+ const makeConstraintViolation = (
339
+ facts: PgErrorFacts,
340
+ cause: unknown,
341
+ ): Option.Option<ConstraintViolation> =>
342
+ Option.map(Record.get(CONSTRAINT_VIOLATION_BY_CODE, facts.code), (make) => make(facts, cause));
343
+
344
+ /** The retryable-transaction classification for these facts, if the SQLSTATE is one of the two. */
345
+ const makeTransactionFailure = (
346
+ facts: PgErrorFacts,
347
+ cause: unknown,
348
+ ): Option.Option<SerializationFailure | DeadlockDetected> =>
349
+ Option.map(Record.get(TRANSACTION_FAILURE_BY_CODE, facts.code), (make) => make(cause));
350
+
351
+ /**
352
+ * Classifies any value into one of the seven tagged errors, by finding the
353
+ * driver's Postgres error in its graph ({@link findPgError}) and dispatching on the
354
+ * SQLSTATE. `Option.none()` when the graph holds no driver error, or holds one
355
+ * whose SQLSTATE is outside the classified families (a syntax error, a
356
+ * connection failure — `SqlError.reason` already covers those well).
357
+ */
358
+ // oxlint-disable-next-line effect/no-unknown-parameters -- public entry over an opaque failure graph (any caught value); narrowing it would break callers
359
+ export const classifyError = (error: unknown): Option.Option<ClassifiedError> =>
360
+ Option.flatMap(findPgError(error), (facts) =>
361
+ Option.orElse<ClassifiedError, ClassifiedError>(makeConstraintViolation(facts, error), () =>
362
+ makeTransactionFailure(facts, error),
363
+ ),
364
+ );
365
+
366
+ /** The `23505` classification for `error`, if that is what it is. */
367
+ const uniqueViolationOf = (cause: unknown): Option.Option<UniqueViolation> =>
368
+ Option.map(
369
+ Option.filter(findPgError(cause), (facts) => facts.code === UNIQUE_VIOLATION),
370
+ (facts) => makeUniqueViolation(facts, cause),
371
+ );
372
+
373
+ /** The constraint-family classification for `error`, if that is what it is. */
374
+ const constraintViolationOf = (cause: unknown): Option.Option<ConstraintViolation> =>
375
+ Option.flatMap(findPgError(cause), (facts) => makeConstraintViolation(facts, cause));
376
+
377
+ // ── Predicates ─────────────────────────────────────────────────────
378
+
379
+ /** True when a node declares `23505` by SQLSTATE, or by the stable Postgres message fragment. */
380
+ const uniqueViolationNode = (node: ErrorNode): Option.Option<true> => {
381
+ const code = Option.orElse(readString(node, "code"), () => readString(node, "sqlState"));
382
+ if (Option.contains(code, UNIQUE_VIOLATION)) return Option.some(true);
383
+ return Option.isSome(
384
+ Option.filter(readString(node, "message"), (message) =>
385
+ message.includes(PG_UNIQUE_VIOLATION_MESSAGE),
386
+ ),
387
+ )
388
+ ? Option.some(true)
389
+ : Option.none();
390
+ };
391
+
392
+ /**
393
+ * True when `error` is (or wraps) a unique-constraint violation.
394
+ *
395
+ * Kept as its own predicate, with its own walk, for adoption parity: this is
396
+ * the name and the semantics the consuming applications already call, and the
397
+ * semantics include a fallback the typed classification deliberately does not
398
+ * have — matching the stable Postgres message fragment when no `code` survived the
399
+ * wrapping. It also matches a `23505` declared under a `code` that is not
400
+ * SQLSTATE-shaped, which {@link classifyError} skips.
401
+ */
402
+ // oxlint-disable-next-line effect/no-unknown-parameters -- public predicate over an opaque failure graph (any caught value); narrowing it would break callers
403
+ export const isUniqueViolation = (error: unknown): boolean =>
404
+ // oxlint-disable-next-line effect/avoid-native-object-helpers -- reference identity is the point
405
+ Option.isSome(searchErrorGraph(error, uniqueViolationNode, 0, new Set()));
406
+
407
+ /**
408
+ * True when `error` is (or wraps) an integrity-constraint violation — one of
409
+ * the five `23xxx` classes {@link classifyError} recognises.
410
+ */
411
+ // oxlint-disable-next-line effect/no-unknown-parameters -- public predicate over an opaque failure graph (any caught value); narrowing it would break callers
412
+ export const isConstraintViolation = (error: unknown): boolean =>
413
+ Option.isSome(constraintViolationOf(error));
414
+
415
+ /**
416
+ * True when `error` is (or wraps) a transaction failure that re-running the
417
+ * whole transaction may clear: a serialization failure (`40001`) or a deadlock
418
+ * (`40P01`). Retrying a {@link ConstraintViolation} is pointless; retrying
419
+ * these is the documented remedy.
420
+ */
421
+ // oxlint-disable-next-line effect/no-unknown-parameters -- public predicate over an opaque failure graph (any caught value); narrowing it would break callers
422
+ export const isRetryableTransactionFailure = (error: unknown): boolean =>
423
+ Option.isSome(
424
+ Option.filter(
425
+ findPgError(error),
426
+ (facts) => facts.code === SERIALIZATION_FAILURE || facts.code === DEADLOCK_DETECTED,
427
+ ),
428
+ );
429
+
430
+ // ── Effect combinators ─────────────────────────────────────────────
431
+
432
+ /**
433
+ * Recovers from a constraint violation in the error channel, leaving every
434
+ * other failure untouched — the failure is re-failed unchanged, with its
435
+ * original type, so a handler for one Postgres conflict does not swallow the
436
+ * rest of the channel.
437
+ *
438
+ * Data-last only: `self.pipe(catchConstraintViolation(handler))`.
439
+ */
440
+ export const catchConstraintViolation =
441
+ <A2, E2, R2>(f: (violation: ConstraintViolation) => Effect.Effect<A2, E2, R2>) =>
442
+ <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A | A2, E | E2, R | R2> =>
443
+ Effect.catch(self, (error: E) =>
444
+ Option.match(constraintViolationOf(error), {
445
+ onNone: (): Effect.Effect<A2, E | E2, R2> => Effect.fail(error),
446
+ onSome: f,
447
+ }),
448
+ );
449
+
450
+ /**
451
+ * {@link catchConstraintViolation} narrowed to `23505`: recovers only a unique
452
+ * violation, passing everything else — including other constraint violations —
453
+ * through unchanged. This is the duplicate-key conflict a consumer maps to a
454
+ * domain "already exists".
455
+ *
456
+ * Data-last only: `self.pipe(catchUniqueViolation(handler))`.
457
+ */
458
+ export const catchUniqueViolation =
459
+ <A2, E2, R2>(f: (violation: UniqueViolation) => Effect.Effect<A2, E2, R2>) =>
460
+ <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A | A2, E | E2, R | R2> =>
461
+ Effect.catch(self, (error: E) =>
462
+ Option.match(uniqueViolationOf(error), {
463
+ onNone: (): Effect.Effect<A2, E | E2, R2> => Effect.fail(error),
464
+ onSome: f,
465
+ }),
466
+ );