honertia 0.1.4 → 0.1.6
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/README.md +264 -216
- package/dist/effect/action.d.ts +6 -3
- package/dist/effect/action.d.ts.map +1 -1
- package/dist/effect/action.js +7 -9
- package/dist/effect/validation.d.ts +35 -13
- package/dist/effect/validation.d.ts.map +1 -1
- package/dist/effect/validation.js +29 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,10 +23,15 @@ I wanted to build on Cloudflare Workers whilst retaining the ergonomics of the L
|
|
|
23
23
|
bun add honertia
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
### Demo
|
|
27
|
+
|
|
28
|
+
Deploy the honertia-worker-demo repo to Cloudflare
|
|
27
29
|
|
|
28
30
|
[](https://deploy.workers.cloudflare.com/?url=https://github.com/PatrickOgilvie/honertia-worker-demo)
|
|
29
31
|
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
|
|
30
35
|
### Recommended File Structure
|
|
31
36
|
|
|
32
37
|
```
|
|
@@ -429,6 +434,225 @@ vite.hmrHead() // HMR preamble script tags for React Fast Refresh
|
|
|
429
434
|
- **Dependencies**:
|
|
430
435
|
- `effect` >= 3.12.0
|
|
431
436
|
|
|
437
|
+
## Anatomy of an Action
|
|
438
|
+
|
|
439
|
+
Actions in Honertia are fully composable Effect computations. Instead of using different action factories for different combinations of features, you opt-in to exactly what you need by yielding services and helpers inside your action.
|
|
440
|
+
|
|
441
|
+
This design is inspired by Laravel's [laravel-actions](https://laravelactions.com/) package, where you opt-in to capabilities by adding methods to your action class. In Honertia, you opt-in by yielding services - the order of your `yield*` statements determines the execution order.
|
|
442
|
+
|
|
443
|
+
### The `action` Wrapper
|
|
444
|
+
|
|
445
|
+
The `action` function is a semantic wrapper that marks an Effect as an action:
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
import { Effect } from 'effect'
|
|
449
|
+
import { action } from 'honertia/effect'
|
|
450
|
+
|
|
451
|
+
export const myAction = action(
|
|
452
|
+
Effect.gen(function* () {
|
|
453
|
+
// Your action logic here
|
|
454
|
+
return new Response('OK')
|
|
455
|
+
})
|
|
456
|
+
)
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
It's intentionally minimal - all the power comes from what you yield inside.
|
|
460
|
+
|
|
461
|
+
### Composable Helpers
|
|
462
|
+
|
|
463
|
+
#### `authorize` - Authentication & Authorization
|
|
464
|
+
|
|
465
|
+
Opt-in to authentication and authorization checks. Returns the authenticated user, fails with `UnauthorizedError` if no user is present, and fails with `ForbiddenError` if the check returns `false`.
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
import { authorize } from 'honertia/effect'
|
|
469
|
+
|
|
470
|
+
// Just require authentication (any logged-in user)
|
|
471
|
+
const auth = yield* authorize()
|
|
472
|
+
|
|
473
|
+
// Require a specific role
|
|
474
|
+
const auth = yield* authorize((a) => a.user.role === 'admin')
|
|
475
|
+
|
|
476
|
+
// Require resource ownership
|
|
477
|
+
const auth = yield* authorize((a) => a.user.id === project.userId)
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
If the check function returns `false`, the action fails immediately with a `ForbiddenError`.
|
|
481
|
+
|
|
482
|
+
#### `validateRequest` - Schema Validation
|
|
483
|
+
|
|
484
|
+
Opt-in to request validation using Effect Schema:
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
import { Schema as S } from 'effect'
|
|
488
|
+
import { validateRequest, requiredString } from 'honertia/effect'
|
|
489
|
+
|
|
490
|
+
const input = yield* validateRequest(
|
|
491
|
+
S.Struct({ name: requiredString, description: S.optional(S.String) }),
|
|
492
|
+
{ errorComponent: 'Projects/Create' }
|
|
493
|
+
)
|
|
494
|
+
// input is fully typed: { name: string, description?: string }
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
On validation failure, re-renders `errorComponent` with field-level errors.
|
|
498
|
+
|
|
499
|
+
#### `DatabaseService` - Database Access
|
|
500
|
+
|
|
501
|
+
Opt-in to database access:
|
|
502
|
+
|
|
503
|
+
```typescript
|
|
504
|
+
import { DatabaseService } from 'honertia/effect'
|
|
505
|
+
|
|
506
|
+
const db = yield* DatabaseService
|
|
507
|
+
const projects = yield* Effect.tryPromise(() =>
|
|
508
|
+
db.query.projects.findMany()
|
|
509
|
+
)
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
#### `render` / `redirect` - Responses
|
|
513
|
+
|
|
514
|
+
Return responses from your action:
|
|
515
|
+
|
|
516
|
+
```typescript
|
|
517
|
+
import { render, redirect } from 'honertia/effect'
|
|
518
|
+
|
|
519
|
+
// Render a page
|
|
520
|
+
return yield* render('Projects/Index', { projects })
|
|
521
|
+
|
|
522
|
+
// Redirect after mutation
|
|
523
|
+
return yield* redirect('/projects')
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### Building an Action
|
|
527
|
+
|
|
528
|
+
Here's how these composables work together:
|
|
529
|
+
|
|
530
|
+
```typescript
|
|
531
|
+
import { Effect, Schema as S } from 'effect'
|
|
532
|
+
import {
|
|
533
|
+
action,
|
|
534
|
+
authorize,
|
|
535
|
+
validateRequest,
|
|
536
|
+
DatabaseService,
|
|
537
|
+
redirect,
|
|
538
|
+
requiredString,
|
|
539
|
+
} from 'honertia/effect'
|
|
540
|
+
|
|
541
|
+
const CreateProjectSchema = S.Struct({
|
|
542
|
+
name: requiredString,
|
|
543
|
+
description: S.optional(S.String),
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
export const createProject = action(
|
|
547
|
+
Effect.gen(function* () {
|
|
548
|
+
// 1. Authorization - fail fast if not allowed
|
|
549
|
+
const auth = yield* authorize((a) => a.user.role === 'author')
|
|
550
|
+
|
|
551
|
+
// 2. Validation - parse and validate request body
|
|
552
|
+
const input = yield* validateRequest(CreateProjectSchema, {
|
|
553
|
+
errorComponent: 'Projects/Create',
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
// 3. Database - perform the mutation
|
|
557
|
+
const db = yield* DatabaseService
|
|
558
|
+
yield* Effect.tryPromise(() =>
|
|
559
|
+
db.insert(projects).values({
|
|
560
|
+
...input,
|
|
561
|
+
userId: auth.user.id,
|
|
562
|
+
})
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
// 4. Response - redirect on success
|
|
566
|
+
return yield* redirect('/projects')
|
|
567
|
+
})
|
|
568
|
+
)
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
### Execution Order Matters
|
|
572
|
+
|
|
573
|
+
The order you yield services determines when they execute:
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
// Authorization BEFORE validation (recommended for most actions)
|
|
577
|
+
// Don't waste cycles validating if user can't perform the action
|
|
578
|
+
const auth = yield* authorize((a) => a.user.role === 'admin')
|
|
579
|
+
const input = yield* validateRequest(schema)
|
|
580
|
+
|
|
581
|
+
// Validation BEFORE authorization (when you need to fetch the resource first)
|
|
582
|
+
// Validate the ID format, fetch from DB, then check ownership against the DB record
|
|
583
|
+
const { id } = yield* validateRequest(Schema.Struct({ id: Schema.UUID }))
|
|
584
|
+
const project = yield* db.findProjectById(id)
|
|
585
|
+
const auth = yield* authorize((a) => a.user.id === project.ownerId)
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
### Type Safety
|
|
589
|
+
|
|
590
|
+
Effect tracks all service requirements at the type level. Your action's type signature shows exactly what it needs:
|
|
591
|
+
|
|
592
|
+
```typescript
|
|
593
|
+
// This action requires: RequestService, DatabaseService
|
|
594
|
+
export const createProject: Effect.Effect<
|
|
595
|
+
Response | Redirect,
|
|
596
|
+
ValidationError | UnauthorizedError | ForbiddenError | Error,
|
|
597
|
+
RequestService | DatabaseService
|
|
598
|
+
>
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
The compiler ensures all required services are provided when the action runs.
|
|
602
|
+
Note: `authorize` uses an optional `AuthUserService`, so it won't appear in the required service list unless you `yield* AuthUserService` directly or provide `RequireAuthLayer` explicitly.
|
|
603
|
+
|
|
604
|
+
### Minimal Actions
|
|
605
|
+
|
|
606
|
+
Not every action needs all features. Use only what you need:
|
|
607
|
+
|
|
608
|
+
```typescript
|
|
609
|
+
// Public page - no auth, no validation
|
|
610
|
+
export const showAbout = action(
|
|
611
|
+
Effect.gen(function* () {
|
|
612
|
+
return yield* render('About', {})
|
|
613
|
+
})
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
// Read-only authenticated page
|
|
617
|
+
export const showDashboard = action(
|
|
618
|
+
Effect.gen(function* () {
|
|
619
|
+
const auth = yield* authorize()
|
|
620
|
+
const db = yield* DatabaseService
|
|
621
|
+
const stats = yield* fetchStats(db, auth)
|
|
622
|
+
return yield* render('Dashboard', { stats })
|
|
623
|
+
})
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
// API endpoint with just validation
|
|
627
|
+
export const searchProjects = action(
|
|
628
|
+
Effect.gen(function* () {
|
|
629
|
+
const { query } = yield* validateRequest(S.Struct({ query: S.String }))
|
|
630
|
+
const db = yield* DatabaseService
|
|
631
|
+
const results = yield* search(db, query)
|
|
632
|
+
return yield* json({ results })
|
|
633
|
+
})
|
|
634
|
+
)
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
### Helper Utilities
|
|
638
|
+
|
|
639
|
+
#### `dbTransaction` - Database Transactions
|
|
640
|
+
|
|
641
|
+
Run multiple database operations in a transaction with automatic rollback on failure. The database instance is passed explicitly to keep the dependency visible and consistent with other service patterns:
|
|
642
|
+
|
|
643
|
+
```typescript
|
|
644
|
+
import { DatabaseService, dbTransaction } from 'honertia/effect'
|
|
645
|
+
|
|
646
|
+
const db = yield* DatabaseService
|
|
647
|
+
|
|
648
|
+
yield* dbTransaction(db, async (tx) => {
|
|
649
|
+
await tx.insert(users).values({ name: 'Alice', email: 'alice@example.com' })
|
|
650
|
+
await tx.update(accounts).set({ balance: 100 }).where(eq(accounts.userId, id))
|
|
651
|
+
// If any operation fails, the entire transaction rolls back
|
|
652
|
+
return { success: true }
|
|
653
|
+
})
|
|
654
|
+
```
|
|
655
|
+
|
|
432
656
|
## Core Concepts
|
|
433
657
|
|
|
434
658
|
### Effect-Based Handlers
|
|
@@ -655,6 +879,45 @@ export const createProject = Effect.gen(function* () {
|
|
|
655
879
|
})
|
|
656
880
|
```
|
|
657
881
|
|
|
882
|
+
### Validation Options
|
|
883
|
+
|
|
884
|
+
`validateRequest` accepts an options object with:
|
|
885
|
+
|
|
886
|
+
```typescript
|
|
887
|
+
const input = yield* validateRequest(schema, {
|
|
888
|
+
// Re-render this component with errors on validation failure
|
|
889
|
+
// If not set, redirects back to the previous page
|
|
890
|
+
errorComponent: 'Projects/Create',
|
|
891
|
+
|
|
892
|
+
// Override default error messages per field
|
|
893
|
+
messages: {
|
|
894
|
+
name: 'Please enter a project name',
|
|
895
|
+
email: 'That email address is not valid',
|
|
896
|
+
},
|
|
897
|
+
|
|
898
|
+
// Human-readable field names for the :attribute placeholder
|
|
899
|
+
// Use with messages like 'The :attribute field is required'
|
|
900
|
+
attributes: {
|
|
901
|
+
name: 'project name',
|
|
902
|
+
email: 'email address',
|
|
903
|
+
},
|
|
904
|
+
})
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
**Example with `:attribute` placeholder:**
|
|
908
|
+
|
|
909
|
+
```typescript
|
|
910
|
+
const schema = S.Struct({
|
|
911
|
+
email: S.String.pipe(S.minLength(1, { message: () => 'The :attribute field is required' })),
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
const input = yield* validateRequest(schema, {
|
|
915
|
+
attributes: { email: 'email address' },
|
|
916
|
+
errorComponent: 'Auth/Register',
|
|
917
|
+
})
|
|
918
|
+
// Error: "The email address field is required"
|
|
919
|
+
```
|
|
920
|
+
|
|
658
921
|
### Available Validators
|
|
659
922
|
|
|
660
923
|
#### Strings
|
|
@@ -972,221 +1235,6 @@ export const logoutUser = betterAuthLogoutAction({
|
|
|
972
1235
|
2. **better-auth returns an error** → Calls your `errorMapper`, then re-renders `errorComponent` with those errors
|
|
973
1236
|
3. **Success** → Sets session cookies from better-auth's response headers, then 303 redirects to `redirectTo`
|
|
974
1237
|
|
|
975
|
-
## Anatomy of an Action
|
|
976
|
-
|
|
977
|
-
Actions in Honertia are fully composable Effect computations. Instead of using different action factories for different combinations of features, you opt-in to exactly what you need by yielding services and helpers inside your action.
|
|
978
|
-
|
|
979
|
-
This design is inspired by Laravel's [laravel-actions](https://laravelactions.com/) package, where you opt-in to capabilities by adding methods to your action class. In Honertia, you opt-in by yielding services - the order of your `yield*` statements determines the execution order.
|
|
980
|
-
|
|
981
|
-
### The `action` Wrapper
|
|
982
|
-
|
|
983
|
-
The `action` function is a semantic wrapper that marks an Effect as an action:
|
|
984
|
-
|
|
985
|
-
```typescript
|
|
986
|
-
import { Effect } from 'effect'
|
|
987
|
-
import { action } from 'honertia/effect'
|
|
988
|
-
|
|
989
|
-
export const myAction = action(
|
|
990
|
-
Effect.gen(function* () {
|
|
991
|
-
// Your action logic here
|
|
992
|
-
return new Response('OK')
|
|
993
|
-
})
|
|
994
|
-
)
|
|
995
|
-
```
|
|
996
|
-
|
|
997
|
-
It's intentionally minimal - all the power comes from what you yield inside.
|
|
998
|
-
|
|
999
|
-
### Composable Helpers
|
|
1000
|
-
|
|
1001
|
-
#### `authorize` - Authentication & Authorization
|
|
1002
|
-
|
|
1003
|
-
Opt-in to authentication and authorization checks. Returns the authenticated user, fails with `UnauthorizedError` if no user is present, and fails with `ForbiddenError` if the check returns `false`.
|
|
1004
|
-
|
|
1005
|
-
```typescript
|
|
1006
|
-
import { authorize } from 'honertia/effect'
|
|
1007
|
-
|
|
1008
|
-
// Just require authentication (any logged-in user)
|
|
1009
|
-
const auth = yield* authorize()
|
|
1010
|
-
|
|
1011
|
-
// Require a specific role
|
|
1012
|
-
const auth = yield* authorize((a) => a.user.role === 'admin')
|
|
1013
|
-
|
|
1014
|
-
// Require resource ownership
|
|
1015
|
-
const auth = yield* authorize((a) => a.user.id === project.userId)
|
|
1016
|
-
```
|
|
1017
|
-
|
|
1018
|
-
If the check function returns `false`, the action fails immediately with a `ForbiddenError`.
|
|
1019
|
-
|
|
1020
|
-
#### `validateRequest` - Schema Validation
|
|
1021
|
-
|
|
1022
|
-
Opt-in to request validation using Effect Schema:
|
|
1023
|
-
|
|
1024
|
-
```typescript
|
|
1025
|
-
import { Schema as S } from 'effect'
|
|
1026
|
-
import { validateRequest, requiredString } from 'honertia/effect'
|
|
1027
|
-
|
|
1028
|
-
const input = yield* validateRequest(
|
|
1029
|
-
S.Struct({ name: requiredString, description: S.optional(S.String) }),
|
|
1030
|
-
{ errorComponent: 'Projects/Create' }
|
|
1031
|
-
)
|
|
1032
|
-
// input is fully typed: { name: string, description?: string }
|
|
1033
|
-
```
|
|
1034
|
-
|
|
1035
|
-
On validation failure, re-renders `errorComponent` with field-level errors.
|
|
1036
|
-
|
|
1037
|
-
#### `DatabaseService` - Database Access
|
|
1038
|
-
|
|
1039
|
-
Opt-in to database access:
|
|
1040
|
-
|
|
1041
|
-
```typescript
|
|
1042
|
-
import { DatabaseService } from 'honertia/effect'
|
|
1043
|
-
|
|
1044
|
-
const db = yield* DatabaseService
|
|
1045
|
-
const projects = yield* Effect.tryPromise(() =>
|
|
1046
|
-
db.query.projects.findMany()
|
|
1047
|
-
)
|
|
1048
|
-
```
|
|
1049
|
-
|
|
1050
|
-
#### `render` / `redirect` - Responses
|
|
1051
|
-
|
|
1052
|
-
Return responses from your action:
|
|
1053
|
-
|
|
1054
|
-
```typescript
|
|
1055
|
-
import { render, redirect } from 'honertia/effect'
|
|
1056
|
-
|
|
1057
|
-
// Render a page
|
|
1058
|
-
return yield* render('Projects/Index', { projects })
|
|
1059
|
-
|
|
1060
|
-
// Redirect after mutation
|
|
1061
|
-
return yield* redirect('/projects')
|
|
1062
|
-
```
|
|
1063
|
-
|
|
1064
|
-
### Building an Action
|
|
1065
|
-
|
|
1066
|
-
Here's how these composables work together:
|
|
1067
|
-
|
|
1068
|
-
```typescript
|
|
1069
|
-
import { Effect, Schema as S } from 'effect'
|
|
1070
|
-
import {
|
|
1071
|
-
action,
|
|
1072
|
-
authorize,
|
|
1073
|
-
validateRequest,
|
|
1074
|
-
DatabaseService,
|
|
1075
|
-
redirect,
|
|
1076
|
-
requiredString,
|
|
1077
|
-
} from 'honertia/effect'
|
|
1078
|
-
|
|
1079
|
-
const CreateProjectSchema = S.Struct({
|
|
1080
|
-
name: requiredString,
|
|
1081
|
-
description: S.optional(S.String),
|
|
1082
|
-
})
|
|
1083
|
-
|
|
1084
|
-
export const createProject = action(
|
|
1085
|
-
Effect.gen(function* () {
|
|
1086
|
-
// 1. Authorization - fail fast if not allowed
|
|
1087
|
-
const auth = yield* authorize((a) => a.user.role === 'author')
|
|
1088
|
-
|
|
1089
|
-
// 2. Validation - parse and validate request body
|
|
1090
|
-
const input = yield* validateRequest(CreateProjectSchema, {
|
|
1091
|
-
errorComponent: 'Projects/Create',
|
|
1092
|
-
})
|
|
1093
|
-
|
|
1094
|
-
// 3. Database - perform the mutation
|
|
1095
|
-
const db = yield* DatabaseService
|
|
1096
|
-
yield* Effect.tryPromise(() =>
|
|
1097
|
-
db.insert(projects).values({
|
|
1098
|
-
...input,
|
|
1099
|
-
userId: auth.user.id,
|
|
1100
|
-
})
|
|
1101
|
-
)
|
|
1102
|
-
|
|
1103
|
-
// 4. Response - redirect on success
|
|
1104
|
-
return yield* redirect('/projects')
|
|
1105
|
-
})
|
|
1106
|
-
)
|
|
1107
|
-
```
|
|
1108
|
-
|
|
1109
|
-
### Execution Order Matters
|
|
1110
|
-
|
|
1111
|
-
The order you yield services determines when they execute:
|
|
1112
|
-
|
|
1113
|
-
```typescript
|
|
1114
|
-
// Authorization BEFORE validation (recommended for mutations)
|
|
1115
|
-
// Don't waste cycles validating if user can't perform the action
|
|
1116
|
-
const auth = yield* authorize((a) => a.user.role === 'admin')
|
|
1117
|
-
const input = yield* validateRequest(schema)
|
|
1118
|
-
|
|
1119
|
-
// Validation BEFORE authorization (when you need input for auth check)
|
|
1120
|
-
const input = yield* validateRequest(schema)
|
|
1121
|
-
const auth = yield* authorize((a) => a.user.id === input.ownerId)
|
|
1122
|
-
```
|
|
1123
|
-
|
|
1124
|
-
### Type Safety
|
|
1125
|
-
|
|
1126
|
-
Effect tracks all service requirements at the type level. Your action's type signature shows exactly what it needs:
|
|
1127
|
-
|
|
1128
|
-
```typescript
|
|
1129
|
-
// This action requires: RequestService, DatabaseService
|
|
1130
|
-
export const createProject: Effect.Effect<
|
|
1131
|
-
Response | Redirect,
|
|
1132
|
-
ValidationError | UnauthorizedError | ForbiddenError | Error,
|
|
1133
|
-
RequestService | DatabaseService
|
|
1134
|
-
>
|
|
1135
|
-
```
|
|
1136
|
-
|
|
1137
|
-
The compiler ensures all required services are provided when the action runs.
|
|
1138
|
-
Note: `authorize` uses an optional `AuthUserService`, so it won't appear in the required service list unless you `yield* AuthUserService` directly or provide `RequireAuthLayer` explicitly.
|
|
1139
|
-
|
|
1140
|
-
### Minimal Actions
|
|
1141
|
-
|
|
1142
|
-
Not every action needs all features. Use only what you need:
|
|
1143
|
-
|
|
1144
|
-
```typescript
|
|
1145
|
-
// Public page - no auth, no validation
|
|
1146
|
-
export const showAbout = action(
|
|
1147
|
-
Effect.gen(function* () {
|
|
1148
|
-
return yield* render('About', {})
|
|
1149
|
-
})
|
|
1150
|
-
)
|
|
1151
|
-
|
|
1152
|
-
// Read-only authenticated page
|
|
1153
|
-
export const showDashboard = action(
|
|
1154
|
-
Effect.gen(function* () {
|
|
1155
|
-
const auth = yield* authorize()
|
|
1156
|
-
const db = yield* DatabaseService
|
|
1157
|
-
const stats = yield* fetchStats(db, auth)
|
|
1158
|
-
return yield* render('Dashboard', { stats })
|
|
1159
|
-
})
|
|
1160
|
-
)
|
|
1161
|
-
|
|
1162
|
-
// API endpoint with just validation
|
|
1163
|
-
export const searchProjects = action(
|
|
1164
|
-
Effect.gen(function* () {
|
|
1165
|
-
const { query } = yield* validateRequest(S.Struct({ query: S.String }))
|
|
1166
|
-
const db = yield* DatabaseService
|
|
1167
|
-
const results = yield* search(db, query)
|
|
1168
|
-
return yield* json({ results })
|
|
1169
|
-
})
|
|
1170
|
-
)
|
|
1171
|
-
```
|
|
1172
|
-
|
|
1173
|
-
### Helper Utilities
|
|
1174
|
-
|
|
1175
|
-
#### `dbTransaction` - Database Transactions
|
|
1176
|
-
|
|
1177
|
-
Run multiple database operations in a transaction with automatic rollback on failure:
|
|
1178
|
-
|
|
1179
|
-
```typescript
|
|
1180
|
-
import { dbTransaction } from 'honertia/effect'
|
|
1181
|
-
|
|
1182
|
-
yield* dbTransaction(async (tx) => {
|
|
1183
|
-
await tx.insert(users).values({ name: 'Alice', email: 'alice@example.com' })
|
|
1184
|
-
await tx.update(accounts).set({ balance: 100 }).where(eq(accounts.userId, id))
|
|
1185
|
-
// If any operation fails, the entire transaction rolls back
|
|
1186
|
-
return { success: true }
|
|
1187
|
-
})
|
|
1188
|
-
```
|
|
1189
|
-
|
|
1190
1238
|
## React Integration
|
|
1191
1239
|
|
|
1192
1240
|
### Page Component Type
|
package/dist/effect/action.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Actions are fully opt-in - yield* only what you need.
|
|
6
6
|
*/
|
|
7
7
|
import { Effect } from 'effect';
|
|
8
|
-
import {
|
|
8
|
+
import { type AuthUser } from './services.js';
|
|
9
9
|
import { UnauthorizedError, ForbiddenError, Redirect } from './errors.js';
|
|
10
10
|
/**
|
|
11
11
|
* Semantic wrapper for Effect actions.
|
|
@@ -57,11 +57,14 @@ export declare function authorize(check?: (user: AuthUser) => boolean): Effect.E
|
|
|
57
57
|
* Automatically rolls back on any failure.
|
|
58
58
|
*
|
|
59
59
|
* @example
|
|
60
|
-
*
|
|
60
|
+
* const db = yield* DatabaseService
|
|
61
|
+
* yield* dbTransaction(db, async (tx) => {
|
|
61
62
|
* await tx.insert(users).values({ name: 'Alice' })
|
|
62
63
|
* await tx.update(accounts).set({ balance: 100 }).where(eq(accounts.userId, id))
|
|
63
64
|
* return { success: true }
|
|
64
65
|
* })
|
|
65
66
|
*/
|
|
66
|
-
export declare function dbTransaction<
|
|
67
|
+
export declare function dbTransaction<DB extends {
|
|
68
|
+
transaction: (fn: (tx: unknown) => Promise<T>) => Promise<T>;
|
|
69
|
+
}, T>(db: DB, operations: (tx: unknown) => Promise<T>): Effect.Effect<T, Error>;
|
|
67
70
|
//# sourceMappingURL=action.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action.d.ts","sourceRoot":"","sources":["../../src/effect/action.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,EAAU,MAAM,QAAQ,CAAA;AACvC,OAAO,
|
|
1
|
+
{"version":3,"file":"action.d.ts","sourceRoot":"","sources":["../../src/effect/action.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,EAAU,MAAM,QAAQ,CAAA;AACvC,OAAO,EAGL,KAAK,QAAQ,EACd,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAEzE;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,MAAM,CAAC,CAAC,EAAE,CAAC,EACzB,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,GAAG,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC,GAChD,MAAM,CAAC,MAAM,CAAC,QAAQ,GAAG,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC,CAE1C;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,SAAS,CACvB,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,OAAO,GAClC,MAAM,CAAC,MAAM,CAAC,QAAQ,EAAE,iBAAiB,GAAG,cAAc,EAAE,KAAK,CAAC,CAoBpE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS;IAAE,WAAW,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAA;CAAE,EAAE,CAAC,EAC1G,EAAE,EAAE,EAAE,EACN,UAAU,EAAE,CAAC,EAAE,EAAE,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,GACtC,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,CAKzB"}
|
package/dist/effect/action.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Actions are fully opt-in - yield* only what you need.
|
|
6
6
|
*/
|
|
7
7
|
import { Effect, Option } from 'effect';
|
|
8
|
-
import {
|
|
8
|
+
import { AuthUserService, } from './services.js';
|
|
9
9
|
import { UnauthorizedError, ForbiddenError } from './errors.js';
|
|
10
10
|
/**
|
|
11
11
|
* Semantic wrapper for Effect actions.
|
|
@@ -74,18 +74,16 @@ export function authorize(check) {
|
|
|
74
74
|
* Automatically rolls back on any failure.
|
|
75
75
|
*
|
|
76
76
|
* @example
|
|
77
|
-
*
|
|
77
|
+
* const db = yield* DatabaseService
|
|
78
|
+
* yield* dbTransaction(db, async (tx) => {
|
|
78
79
|
* await tx.insert(users).values({ name: 'Alice' })
|
|
79
80
|
* await tx.update(accounts).set({ balance: 100 }).where(eq(accounts.userId, id))
|
|
80
81
|
* return { success: true }
|
|
81
82
|
* })
|
|
82
83
|
*/
|
|
83
|
-
export function dbTransaction(operations) {
|
|
84
|
-
return Effect.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
try: () => db.transaction(operations),
|
|
88
|
-
catch: (error) => error instanceof Error ? error : new Error(String(error)),
|
|
89
|
-
});
|
|
84
|
+
export function dbTransaction(db, operations) {
|
|
85
|
+
return Effect.tryPromise({
|
|
86
|
+
try: () => db.transaction(operations),
|
|
87
|
+
catch: (error) => error instanceof Error ? error : new Error(String(error)),
|
|
90
88
|
});
|
|
91
89
|
}
|
|
@@ -8,31 +8,53 @@ import { RequestService } from './services.js';
|
|
|
8
8
|
import { ValidationError } from './errors.js';
|
|
9
9
|
/**
|
|
10
10
|
* Extract validation data from the request.
|
|
11
|
-
* Merges route params, query params, and body.
|
|
11
|
+
* Merges route params, query params, and body (body takes precedence).
|
|
12
12
|
*/
|
|
13
|
-
export declare const getValidationData: Effect.Effect<
|
|
14
|
-
[x: string]: unknown;
|
|
15
|
-
}, never, RequestService>;
|
|
13
|
+
export declare const getValidationData: Effect.Effect<Record<string, unknown>, ValidationError, RequestService>;
|
|
16
14
|
/**
|
|
17
15
|
* Format Effect Schema parse errors into field-level validation errors.
|
|
18
16
|
*/
|
|
19
17
|
export declare function formatSchemaErrors(error: ParseResult.ParseError, messages?: Record<string, string>, attributes?: Record<string, string>): Record<string, string>;
|
|
20
18
|
/**
|
|
21
|
-
*
|
|
22
|
-
* Returns validated data or fails with ValidationError.
|
|
19
|
+
* Options for validation functions.
|
|
23
20
|
*/
|
|
24
|
-
export
|
|
21
|
+
export interface ValidateOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Custom error messages keyed by field name.
|
|
24
|
+
* Overrides the default messages from Effect Schema.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* { email: 'Please enter a valid email address' }
|
|
28
|
+
*/
|
|
25
29
|
messages?: Record<string, string>;
|
|
30
|
+
/**
|
|
31
|
+
* Human-readable names for fields, used with the `:attribute` placeholder.
|
|
32
|
+
* If a message contains `:attribute`, it will be replaced with the value here.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* // With attributes: { email: 'email address' }
|
|
36
|
+
* // And message: 'The :attribute field is required'
|
|
37
|
+
* // Produces: 'The email address field is required'
|
|
38
|
+
*/
|
|
26
39
|
attributes?: Record<string, string>;
|
|
40
|
+
/**
|
|
41
|
+
* The Inertia component to re-render when validation fails.
|
|
42
|
+
* If set, a ValidationError will trigger a re-render of this component
|
|
43
|
+
* with the errors passed as props. If not set, redirects back.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* 'Projects/Create'
|
|
47
|
+
*/
|
|
27
48
|
errorComponent?: string;
|
|
28
|
-
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Validate data against a schema.
|
|
52
|
+
* Returns validated data or fails with ValidationError.
|
|
53
|
+
*/
|
|
54
|
+
export declare function validate<A, I>(schema: S.Schema<A, I>, data: unknown, options?: ValidateOptions): Effect.Effect<A, ValidationError, never>;
|
|
29
55
|
/**
|
|
30
56
|
* Validate request data against a schema.
|
|
31
57
|
* Extracts data from request and validates in one step.
|
|
32
58
|
*/
|
|
33
|
-
export declare
|
|
34
|
-
messages?: Record<string, string>;
|
|
35
|
-
attributes?: Record<string, string>;
|
|
36
|
-
errorComponent?: string;
|
|
37
|
-
}) => Effect.Effect<A, ValidationError, RequestService>;
|
|
59
|
+
export declare function validateRequest<A, I>(schema: S.Schema<A, I>, options?: ValidateOptions): Effect.Effect<A, ValidationError, RequestService>;
|
|
38
60
|
//# sourceMappingURL=validation.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/effect/validation.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,IAAI,CAAC,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAA;AACzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAC9C,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAE7C;;;GAGG;AACH,eAAO,MAAM,iBAAiB
|
|
1
|
+
{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/effect/validation.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,IAAI,CAAC,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAA;AACzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAC9C,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAE7C;;;GAGG;AACH,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,MAAM,CAC3C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACvB,eAAe,EACf,cAAc,CA0Bd,CAAA;AAEF;;GAEG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,WAAW,CAAC,UAAU,EAC7B,QAAQ,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,EACrC,UAAU,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GACtC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAgBxB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAEjC;;;;;;;;OAQG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAEnC;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EAAE,CAAC,EAC3B,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,EACtB,IAAI,EAAE,OAAO,EACb,OAAO,GAAE,eAAoB,GAC5B,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,eAAe,EAAE,KAAK,CAAC,CAS1C;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,CAAC,EAClC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,EACtB,OAAO,GAAE,eAAoB,GAC5B,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,eAAe,EAAE,cAAc,CAAC,CAKnD"}
|
|
@@ -8,46 +8,36 @@ import { RequestService } from './services.js';
|
|
|
8
8
|
import { ValidationError } from './errors.js';
|
|
9
9
|
/**
|
|
10
10
|
* Extract validation data from the request.
|
|
11
|
-
* Merges route params, query params, and body.
|
|
11
|
+
* Merges route params, query params, and body (body takes precedence).
|
|
12
12
|
*/
|
|
13
13
|
export const getValidationData = Effect.gen(function* () {
|
|
14
14
|
const request = yield* RequestService;
|
|
15
15
|
const routeParams = request.params();
|
|
16
16
|
const queryParams = request.query();
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
const bodyResult = yield* Effect.tryPromise({
|
|
21
|
-
try: () => contentType.includes('application/json')
|
|
22
|
-
? request.json()
|
|
23
|
-
: request.parseBody(),
|
|
24
|
-
catch: () => ({}),
|
|
25
|
-
}).pipe(Effect.catchAll(() => Effect.succeed({})));
|
|
26
|
-
body = bodyResult;
|
|
17
|
+
// Only parse body for methods that typically have one
|
|
18
|
+
if (['GET', 'HEAD'].includes(request.method.toUpperCase())) {
|
|
19
|
+
return { ...routeParams, ...queryParams };
|
|
27
20
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
};
|
|
21
|
+
const contentType = request.header('Content-Type') ?? '';
|
|
22
|
+
const isJson = contentType.includes('application/json');
|
|
23
|
+
const body = yield* Effect.tryPromise(() => isJson ? request.json() : request.parseBody()).pipe(Effect.mapError(() => new ValidationError({
|
|
24
|
+
errors: { form: isJson ? 'Invalid JSON body' : 'Could not parse request body' },
|
|
25
|
+
})));
|
|
26
|
+
return { ...routeParams, ...queryParams, ...body };
|
|
33
27
|
});
|
|
34
28
|
/**
|
|
35
29
|
* Format Effect Schema parse errors into field-level validation errors.
|
|
36
30
|
*/
|
|
37
31
|
export function formatSchemaErrors(error, messages = {}, attributes = {}) {
|
|
38
32
|
const errors = {};
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
? issue.path.map(p => typeof p === 'object' && p !== null && 'key' in p ? p.key : String(p)).join('.')
|
|
44
|
-
: 'form';
|
|
45
|
-
if (errors[pathStr])
|
|
33
|
+
const issues = ParseResult.ArrayFormatter.formatErrorSync(error);
|
|
34
|
+
for (const issue of issues) {
|
|
35
|
+
const field = issue.path.length > 0 ? issue.path.map(String).join('.') : 'form';
|
|
36
|
+
if (errors[field])
|
|
46
37
|
continue; // First error wins
|
|
47
|
-
const attribute = attributes[
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
errors[pathStr] = message.replace(/:attribute/g, attribute);
|
|
38
|
+
const attribute = attributes[field] ?? field;
|
|
39
|
+
const message = messages[field] ?? issue.message;
|
|
40
|
+
errors[field] = message.replace(/:attribute/g, attribute);
|
|
51
41
|
}
|
|
52
42
|
return errors;
|
|
53
43
|
}
|
|
@@ -55,15 +45,19 @@ export function formatSchemaErrors(error, messages = {}, attributes = {}) {
|
|
|
55
45
|
* Validate data against a schema.
|
|
56
46
|
* Returns validated data or fails with ValidationError.
|
|
57
47
|
*/
|
|
58
|
-
export
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
48
|
+
export function validate(schema, data, options = {}) {
|
|
49
|
+
return S.decodeUnknown(schema)(data).pipe(Effect.mapError((error) => new ValidationError({
|
|
50
|
+
errors: formatSchemaErrors(error, options.messages, options.attributes),
|
|
51
|
+
component: options.errorComponent,
|
|
52
|
+
})));
|
|
53
|
+
}
|
|
62
54
|
/**
|
|
63
55
|
* Validate request data against a schema.
|
|
64
56
|
* Extracts data from request and validates in one step.
|
|
65
57
|
*/
|
|
66
|
-
export
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
58
|
+
export function validateRequest(schema, options = {}) {
|
|
59
|
+
return Effect.gen(function* () {
|
|
60
|
+
const data = yield* getValidationData;
|
|
61
|
+
return yield* validate(schema, data, options);
|
|
62
|
+
});
|
|
63
|
+
}
|