honertia 0.1.3 → 0.1.5

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 CHANGED
@@ -433,47 +433,55 @@ vite.hmrHead() // HMR preamble script tags for React Fast Refresh
433
433
 
434
434
  ### Effect-Based Handlers
435
435
 
436
- Route handlers are Effect computations that return `Response | Redirect`:
436
+ Route handlers are Effect computations that return `Response | Redirect`. Actions are fully composable - you opt-in to features by yielding services:
437
437
 
438
438
  ```typescript
439
439
  import { Effect } from 'effect'
440
440
  import {
441
+ action,
442
+ authorize,
443
+ validateRequest,
441
444
  DatabaseService,
442
- AuthUserService,
443
445
  render,
444
446
  redirect,
445
- } from 'honertia'
446
-
447
- // Simple page render
448
- export const showDashboard = Effect.gen(function* () {
449
- const db = yield* DatabaseService
450
- const user = yield* AuthUserService
447
+ } from 'honertia/effect'
451
448
 
452
- const projects = yield* Effect.tryPromise(() =>
453
- db.query.projects.findMany({
454
- where: eq(schema.projects.userId, user.user.id),
455
- limit: 5,
456
- })
457
- )
449
+ // Simple page render with auth
450
+ export const showDashboard = action(
451
+ Effect.gen(function* () {
452
+ const auth = yield* authorize()
453
+ const db = yield* DatabaseService
458
454
 
459
- return yield* render('Dashboard/Index', { projects })
460
- })
455
+ const projects = yield* Effect.tryPromise(() =>
456
+ db.query.projects.findMany({
457
+ where: eq(schema.projects.userId, auth.user.id),
458
+ limit: 5,
459
+ })
460
+ )
461
461
 
462
- // Form submission with redirect
463
- export const createProject = Effect.gen(function* () {
464
- const db = yield* DatabaseService
465
- const user = yield* AuthUserService
466
- const input = yield* validateRequest(CreateProjectSchema)
462
+ return yield* render('Dashboard/Index', { projects })
463
+ })
464
+ )
467
465
 
468
- yield* Effect.tryPromise(() =>
469
- db.insert(schema.projects).values({
470
- ...input,
471
- userId: user.user.id,
466
+ // Form submission with permissions, validation, and redirect
467
+ export const createProject = action(
468
+ Effect.gen(function* () {
469
+ const auth = yield* authorize((a) => a.user.role === 'admin')
470
+ const input = yield* validateRequest(CreateProjectSchema, {
471
+ errorComponent: 'Projects/Create',
472
472
  })
473
- )
473
+ const db = yield* DatabaseService
474
474
 
475
- return yield* redirect('/projects')
476
- })
475
+ yield* Effect.tryPromise(() =>
476
+ db.insert(schema.projects).values({
477
+ ...input,
478
+ userId: auth.user.id,
479
+ })
480
+ )
481
+
482
+ return yield* redirect('/projects')
483
+ })
484
+ )
477
485
  ```
478
486
 
479
487
  ### Services
@@ -964,44 +972,223 @@ export const logoutUser = betterAuthLogoutAction({
964
972
  2. **better-auth returns an error** → Calls your `errorMapper`, then re-renders `errorComponent` with those errors
965
973
  3. **Success** → Sets session cookies from better-auth's response headers, then 303 redirects to `redirectTo`
966
974
 
967
- ## Action Factories
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.
968
978
 
969
- For common patterns, use action factories:
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:
970
984
 
971
985
  ```typescript
972
- import { effectAction, dbAction, authAction } from 'honertia'
973
-
974
- // effectAction: validation + custom handler
975
- export const updateSettings = effectAction(
976
- SettingsSchema,
977
- (input) => Effect.gen(function* () {
978
- yield* saveSettings(input)
979
- return yield* redirect('/settings')
980
- }),
981
- { errorComponent: 'Settings/Edit' }
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()
982
1047
  )
1048
+ ```
983
1049
 
984
- // dbAction: validation + db + auth
985
- export const createProject = dbAction(
986
- CreateProjectSchema,
987
- (input, { db, user }) => Effect.gen(function* () {
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
988
1096
  yield* Effect.tryPromise(() =>
989
- db.insert(projects).values({ ...input, userId: user.user.id })
1097
+ db.insert(projects).values({
1098
+ ...input,
1099
+ userId: auth.user.id,
1100
+ })
990
1101
  )
1102
+
1103
+ // 4. Response - redirect on success
991
1104
  return yield* redirect('/projects')
992
- }),
993
- { errorComponent: 'Projects/Create' }
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
+ })
994
1160
  )
995
1161
 
996
- // authAction: just requires auth
997
- export const showDashboard = authAction((user) =>
1162
+ // API endpoint with just validation
1163
+ export const searchProjects = action(
998
1164
  Effect.gen(function* () {
999
- const data = yield* fetchDashboardData(user)
1000
- return yield* render('Dashboard', data)
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 })
1001
1169
  })
1002
1170
  )
1003
1171
  ```
1004
1172
 
1173
+ ### Helper Utilities
1174
+
1175
+ #### `dbTransaction` - Database Transactions
1176
+
1177
+ 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:
1178
+
1179
+ ```typescript
1180
+ import { DatabaseService, dbTransaction } from 'honertia/effect'
1181
+
1182
+ const db = yield* DatabaseService
1183
+
1184
+ yield* dbTransaction(db, async (tx) => {
1185
+ await tx.insert(users).values({ name: 'Alice', email: 'alice@example.com' })
1186
+ await tx.update(accounts).set({ balance: 100 }).where(eq(accounts.userId, id))
1187
+ // If any operation fails, the entire transaction rolls back
1188
+ return { success: true }
1189
+ })
1190
+ ```
1191
+
1005
1192
  ## React Integration
1006
1193
 
1007
1194
  ### Page Component Type
@@ -1,107 +1,70 @@
1
1
  /**
2
- * Effect Action Factories
2
+ * Effect Action Composables
3
3
  *
4
- * Pure function factories for creating Effect-based request handlers.
4
+ * Composable helpers for building Effect-based request handlers.
5
+ * Actions are fully opt-in - yield* only what you need.
5
6
  */
6
- import { Effect, Schema as S } from 'effect';
7
- import { DatabaseService, AuthUserService, RequestService, type AuthUser } from './services.js';
8
- import { ValidationError, UnauthorizedError } from './errors.js';
9
- import { Redirect } from './errors.js';
7
+ import { Effect } from 'effect';
8
+ import { type AuthUser } from './services.js';
9
+ import { UnauthorizedError, ForbiddenError, Redirect } from './errors.js';
10
10
  /**
11
- * Create an Effect action with schema validation.
11
+ * Semantic wrapper for Effect actions.
12
+ *
13
+ * This is a minimal wrapper that marks an Effect as an action.
14
+ * All capabilities are opt-in via yield* inside your handler.
12
15
  *
13
16
  * @example
14
- * const createProject = effectAction(
15
- * S.Struct({ name: requiredString }),
16
- * (input) => Effect.gen(function* () {
17
+ * const createProject = action(
18
+ * Effect.gen(function* () {
19
+ * // Opt-in to authorization
20
+ * const auth = yield* authorize()
21
+ *
22
+ * // Opt-in to validation
23
+ * const input = yield* validateRequest(S.Struct({ name: requiredString }))
24
+ *
25
+ * // Opt-in to database
17
26
  * const db = yield* DatabaseService
18
- * yield* Effect.tryPromise(() => db.insert(projects).values(input))
19
- * return new Redirect({ url: '/projects', status: 303 })
20
- * })
21
- * )
22
- */
23
- export declare function effectAction<A, I, R, E>(schema: S.Schema<A, I>, handler: (input: A) => Effect.Effect<Response | Redirect, E, R>, options?: {
24
- errorComponent?: string;
25
- messages?: Record<string, string>;
26
- attributes?: Record<string, string>;
27
- }): Effect.Effect<Response | Redirect, E | ValidationError, R | RequestService>;
28
- /**
29
- * Create an Effect action that requires authentication and database access.
30
27
  *
31
- * @example
32
- * const createProject = dbAction(
33
- * S.Struct({ name: requiredString }),
34
- * (input, { db, user }) => Effect.gen(function* () {
35
28
  * yield* Effect.tryPromise(() =>
36
- * db.insert(projects).values({ ...input, userId: user.user.id })
29
+ * db.insert(projects).values({ ...input, userId: auth.user.id })
37
30
  * )
38
31
  * return new Redirect({ url: '/projects', status: 303 })
39
32
  * })
40
33
  * )
41
34
  */
42
- export declare function dbAction<A, I, E>(schema: S.Schema<A, I>, handler: (input: A, deps: {
43
- db: unknown;
44
- user: AuthUser;
45
- }) => Effect.Effect<Response | Redirect, E, never>, options?: {
46
- errorComponent?: string;
47
- messages?: Record<string, string>;
48
- attributes?: Record<string, string>;
49
- }): Effect.Effect<Response | Redirect, E | ValidationError | UnauthorizedError, RequestService | DatabaseService | AuthUserService>;
35
+ export declare function action<R, E>(handler: Effect.Effect<Response | Redirect, E, R>): Effect.Effect<Response | Redirect, E, R>;
50
36
  /**
51
- * Create an Effect action that requires authentication.
37
+ * Authorization helper - opt-in to auth check.
52
38
  *
53
- * @example
54
- * const showDashboard = authAction(() => Effect.gen(function* () {
55
- * const user = yield* AuthUserService
56
- * const honertia = yield* HonertiaService
57
- * return yield* Effect.tryPromise(() => honertia.render('Dashboard', { user: user.user }))
58
- * }))
59
- */
60
- export declare function authAction<R, E>(handler: (user: AuthUser) => Effect.Effect<Response | Redirect, E, R>): Effect.Effect<Response | Redirect, E | UnauthorizedError, R | AuthUserService>;
61
- /**
62
- * Create a simple Effect action without validation.
39
+ * Returns the authenticated user if authorized.
40
+ * Fails with UnauthorizedError if no user is present.
41
+ * Fails with ForbiddenError if the check returns false.
42
+ * The check function is optional - if not provided, just requires authentication.
63
43
  *
64
44
  * @example
65
- * const listProjects = simpleAction(() => Effect.gen(function* () {
66
- * const db = yield* DatabaseService
67
- * const user = yield* AuthUserService
68
- * const projects = yield* Effect.tryPromise(() => db.query.projects.findMany())
69
- * const honertia = yield* HonertiaService
70
- * return yield* Effect.tryPromise(() => honertia.render('Projects', { projects }))
71
- * }))
45
+ * // Just require authentication
46
+ * const auth = yield* authorize()
47
+ *
48
+ * // Require specific role (if your user type has a role field)
49
+ * const auth = yield* authorize((a) => a.user.role === 'admin')
50
+ *
51
+ * // Require resource ownership
52
+ * const auth = yield* authorize((a) => a.user.id === project.userId)
72
53
  */
73
- export declare function simpleAction<R, E>(handler: () => Effect.Effect<Response | Redirect, E, R>): Effect.Effect<Response | Redirect, E, R>;
54
+ export declare function authorize(check?: (user: AuthUser) => boolean): Effect.Effect<AuthUser, UnauthorizedError | ForbiddenError, never>;
74
55
  /**
75
- * Inject additional data into the validated input.
56
+ * Run multiple database operations in a transaction.
57
+ * Automatically rolls back on any failure.
76
58
  *
77
59
  * @example
78
- * const createProject = effectAction(
79
- * S.Struct({ name: requiredString }),
80
- * (input) => pipe(
81
- * injectUser(input),
82
- * Effect.flatMap(({ name, userId }) =>
83
- * Effect.tryPromise(() => db.insert(projects).values({ name, userId }))
84
- * )
85
- * )
86
- * )
87
- */
88
- export declare function injectUser<T extends Record<string, unknown>>(input: T): Effect.Effect<T & {
89
- userId: string;
90
- }, UnauthorizedError, AuthUserService>;
91
- /**
92
- * Run a database operation wrapped in Effect.
93
- */
94
- export declare function dbOperation<T>(operation: (db: unknown) => Promise<T>): Effect.Effect<T, Error, DatabaseService>;
95
- /**
96
- * Prepare validation data by transforming it before validation.
97
- */
98
- export declare function prepareData<T extends Record<string, unknown>>(transform: (data: Record<string, unknown>) => T | Promise<T>): Effect.Effect<T, never, RequestService>;
99
- /**
100
- * Create an action with custom data preparation.
60
+ * const db = yield* DatabaseService
61
+ * yield* dbTransaction(db, async (tx) => {
62
+ * await tx.insert(users).values({ name: 'Alice' })
63
+ * await tx.update(accounts).set({ balance: 100 }).where(eq(accounts.userId, id))
64
+ * return { success: true }
65
+ * })
101
66
  */
102
- export declare function preparedAction<A, I, R, E>(schema: S.Schema<A, I>, prepare: (data: Record<string, unknown>) => Record<string, unknown> | Promise<Record<string, unknown>>, handler: (input: A) => Effect.Effect<Response | Redirect, E, R>, options?: {
103
- errorComponent?: string;
104
- messages?: Record<string, string>;
105
- attributes?: Record<string, string>;
106
- }): Effect.Effect<Response | Redirect, E | ValidationError, R | RequestService>;
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>;
107
70
  //# sourceMappingURL=action.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"action.d.ts","sourceRoot":"","sources":["../../src/effect/action.ts"],"names":[],"mappings":"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"}
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"}
@@ -1,150 +1,89 @@
1
1
  /**
2
- * Effect Action Factories
2
+ * Effect Action Composables
3
3
  *
4
- * Pure function factories for creating Effect-based request handlers.
4
+ * Composable helpers for building Effect-based request handlers.
5
+ * Actions are fully opt-in - yield* only what you need.
5
6
  */
6
- import { Effect } from 'effect';
7
- import { DatabaseService, AuthUserService, } from './services.js';
8
- import { validateRequest, getValidationData, validate } from './validation.js';
7
+ import { Effect, Option } from 'effect';
8
+ import { AuthUserService, } from './services.js';
9
+ import { UnauthorizedError, ForbiddenError } from './errors.js';
9
10
  /**
10
- * Create an Effect action with schema validation.
11
+ * Semantic wrapper for Effect actions.
12
+ *
13
+ * This is a minimal wrapper that marks an Effect as an action.
14
+ * All capabilities are opt-in via yield* inside your handler.
11
15
  *
12
16
  * @example
13
- * const createProject = effectAction(
14
- * S.Struct({ name: requiredString }),
15
- * (input) => Effect.gen(function* () {
17
+ * const createProject = action(
18
+ * Effect.gen(function* () {
19
+ * // Opt-in to authorization
20
+ * const auth = yield* authorize()
21
+ *
22
+ * // Opt-in to validation
23
+ * const input = yield* validateRequest(S.Struct({ name: requiredString }))
24
+ *
25
+ * // Opt-in to database
16
26
  * const db = yield* DatabaseService
17
- * yield* Effect.tryPromise(() => db.insert(projects).values(input))
18
- * return new Redirect({ url: '/projects', status: 303 })
19
- * })
20
- * )
21
- */
22
- export function effectAction(schema, handler, options) {
23
- return Effect.gen(function* () {
24
- const input = yield* validateRequest(schema, {
25
- errorComponent: options?.errorComponent,
26
- messages: options?.messages,
27
- attributes: options?.attributes,
28
- });
29
- return yield* handler(input);
30
- });
31
- }
32
- /**
33
- * Create an Effect action that requires authentication and database access.
34
27
  *
35
- * @example
36
- * const createProject = dbAction(
37
- * S.Struct({ name: requiredString }),
38
- * (input, { db, user }) => Effect.gen(function* () {
39
28
  * yield* Effect.tryPromise(() =>
40
- * db.insert(projects).values({ ...input, userId: user.user.id })
29
+ * db.insert(projects).values({ ...input, userId: auth.user.id })
41
30
  * )
42
31
  * return new Redirect({ url: '/projects', status: 303 })
43
32
  * })
44
33
  * )
45
34
  */
46
- export function dbAction(schema, handler, options) {
47
- return Effect.gen(function* () {
48
- const db = yield* DatabaseService;
49
- const user = yield* AuthUserService;
50
- const input = yield* validateRequest(schema, {
51
- errorComponent: options?.errorComponent,
52
- messages: options?.messages,
53
- attributes: options?.attributes,
54
- });
55
- return yield* handler(input, { db, user });
56
- });
35
+ export function action(handler) {
36
+ return handler;
57
37
  }
58
38
  /**
59
- * Create an Effect action that requires authentication.
39
+ * Authorization helper - opt-in to auth check.
60
40
  *
61
- * @example
62
- * const showDashboard = authAction(() => Effect.gen(function* () {
63
- * const user = yield* AuthUserService
64
- * const honertia = yield* HonertiaService
65
- * return yield* Effect.tryPromise(() => honertia.render('Dashboard', { user: user.user }))
66
- * }))
67
- */
68
- export function authAction(handler) {
69
- return Effect.gen(function* () {
70
- const user = yield* AuthUserService;
71
- return yield* handler(user);
72
- });
73
- }
74
- /**
75
- * Create a simple Effect action without validation.
41
+ * Returns the authenticated user if authorized.
42
+ * Fails with UnauthorizedError if no user is present.
43
+ * Fails with ForbiddenError if the check returns false.
44
+ * The check function is optional - if not provided, just requires authentication.
76
45
  *
77
46
  * @example
78
- * const listProjects = simpleAction(() => Effect.gen(function* () {
79
- * const db = yield* DatabaseService
80
- * const user = yield* AuthUserService
81
- * const projects = yield* Effect.tryPromise(() => db.query.projects.findMany())
82
- * const honertia = yield* HonertiaService
83
- * return yield* Effect.tryPromise(() => honertia.render('Projects', { projects }))
84
- * }))
85
- */
86
- export function simpleAction(handler) {
87
- return handler();
88
- }
89
- /**
90
- * Inject additional data into the validated input.
47
+ * // Just require authentication
48
+ * const auth = yield* authorize()
91
49
  *
92
- * @example
93
- * const createProject = effectAction(
94
- * S.Struct({ name: requiredString }),
95
- * (input) => pipe(
96
- * injectUser(input),
97
- * Effect.flatMap(({ name, userId }) =>
98
- * Effect.tryPromise(() => db.insert(projects).values({ name, userId }))
99
- * )
100
- * )
101
- * )
102
- */
103
- export function injectUser(input) {
104
- return Effect.gen(function* () {
105
- const authUser = yield* AuthUserService;
106
- return { ...input, userId: authUser.user.id };
107
- });
108
- }
109
- /**
110
- * Run a database operation wrapped in Effect.
111
- */
112
- export function dbOperation(operation) {
113
- return Effect.gen(function* () {
114
- const db = yield* DatabaseService;
115
- return yield* Effect.tryPromise({
116
- try: () => operation(db),
117
- catch: (error) => error instanceof Error ? error : new Error(String(error)),
118
- });
119
- });
120
- }
121
- /**
122
- * Prepare validation data by transforming it before validation.
50
+ * // Require specific role (if your user type has a role field)
51
+ * const auth = yield* authorize((a) => a.user.role === 'admin')
52
+ *
53
+ * // Require resource ownership
54
+ * const auth = yield* authorize((a) => a.user.id === project.userId)
123
55
  */
124
- export function prepareData(transform) {
56
+ export function authorize(check) {
125
57
  return Effect.gen(function* () {
126
- const data = yield* getValidationData;
127
- return yield* Effect.tryPromise({
128
- try: () => Promise.resolve(transform(data)),
129
- catch: () => new Error('Transform failed'),
130
- }).pipe(Effect.catchAll(() => Effect.succeed(data)));
58
+ const maybeUser = yield* Effect.serviceOption(AuthUserService);
59
+ if (Option.isNone(maybeUser)) {
60
+ return yield* Effect.fail(new UnauthorizedError({
61
+ message: 'Authentication required',
62
+ redirectTo: '/login',
63
+ }));
64
+ }
65
+ const user = maybeUser.value;
66
+ if (check && !check(user)) {
67
+ return yield* Effect.fail(new ForbiddenError({ message: 'Not authorized' }));
68
+ }
69
+ return user;
131
70
  });
132
71
  }
133
72
  /**
134
- * Create an action with custom data preparation.
73
+ * Run multiple database operations in a transaction.
74
+ * Automatically rolls back on any failure.
75
+ *
76
+ * @example
77
+ * const db = yield* DatabaseService
78
+ * yield* dbTransaction(db, async (tx) => {
79
+ * await tx.insert(users).values({ name: 'Alice' })
80
+ * await tx.update(accounts).set({ balance: 100 }).where(eq(accounts.userId, id))
81
+ * return { success: true }
82
+ * })
135
83
  */
136
- export function preparedAction(schema, prepare, handler, options) {
137
- return Effect.gen(function* () {
138
- const rawData = yield* getValidationData;
139
- const preparedData = yield* Effect.tryPromise({
140
- try: () => Promise.resolve(prepare(rawData)),
141
- catch: () => new Error('Prepare failed'),
142
- }).pipe(Effect.catchAll(() => Effect.succeed(rawData)));
143
- const input = yield* validate(schema, {
144
- errorComponent: options?.errorComponent,
145
- messages: options?.messages,
146
- attributes: options?.attributes,
147
- })(preparedData);
148
- return yield* handler(input);
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)),
149
88
  });
150
89
  }
@@ -9,7 +9,7 @@ export * from './schema.js';
9
9
  export { getValidationData, formatSchemaErrors, validate, validateRequest, } from './validation.js';
10
10
  export { effectBridge, buildContextLayer, getEffectRuntime, type EffectBridgeConfig, } from './bridge.js';
11
11
  export { effectHandler, effect, handle, errorToResponse, } from './handler.js';
12
- export { effectAction, dbAction, authAction, simpleAction, injectUser, dbOperation, prepareData, preparedAction, } from './action.js';
12
+ export { action, authorize, dbTransaction, } from './action.js';
13
13
  export { redirect, render, renderWithErrors, json, text, notFound, forbidden, httpError, prefersJson, jsonOrRender, share, } from './responses.js';
14
14
  export { EffectRouteBuilder, effectRoutes, type EffectHandler, type BaseServices, } from './routing.js';
15
15
  export { RequireAuthLayer, RequireGuestLayer, isAuthenticated, currentUser, requireAuth, requireGuest, shareAuth, shareAuthMiddleware, effectAuthRoutes, betterAuthFormAction, betterAuthLogoutAction, loadUser, type AuthRoutesConfig, type BetterAuthFormActionConfig, type BetterAuthLogoutConfig, type BetterAuthActionResult, } from './auth.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/effect/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EACL,eAAe,EACf,WAAW,EACX,eAAe,EACf,eAAe,EACf,cAAc,EACd,sBAAsB,EACtB,KAAK,QAAQ,EACb,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,eAAe,GACrB,MAAM,eAAe,CAAA;AAGtB,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,SAAS,EACT,QAAQ,EACR,KAAK,QAAQ,GACd,MAAM,aAAa,CAAA;AAGpB,cAAc,aAAa,CAAA;AAG3B,OAAO,EACL,iBAAiB,EACjB,kBAAkB,EAClB,QAAQ,EACR,eAAe,GAChB,MAAM,iBAAiB,CAAA;AAGxB,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,gBAAgB,EAChB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAA;AAGpB,OAAO,EACL,aAAa,EACb,MAAM,EACN,MAAM,EACN,eAAe,GAChB,MAAM,cAAc,CAAA;AAGrB,OAAO,EACL,YAAY,EACZ,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,UAAU,EACV,WAAW,EACX,WAAW,EACX,cAAc,GACf,MAAM,aAAa,CAAA;AAGpB,OAAO,EACL,QAAQ,EACR,MAAM,EACN,gBAAgB,EAChB,IAAI,EACJ,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,SAAS,EACT,WAAW,EACX,YAAY,EACZ,KAAK,GACN,MAAM,gBAAgB,CAAA;AAGvB,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,cAAc,CAAA;AAGrB,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,WAAW,EACX,YAAY,EACZ,SAAS,EACT,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,sBAAsB,EACtB,QAAQ,EACR,KAAK,gBAAgB,EACrB,KAAK,0BAA0B,EAC/B,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC5B,MAAM,WAAW,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/effect/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EACL,eAAe,EACf,WAAW,EACX,eAAe,EACf,eAAe,EACf,cAAc,EACd,sBAAsB,EACtB,KAAK,QAAQ,EACb,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,eAAe,GACrB,MAAM,eAAe,CAAA;AAGtB,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,SAAS,EACT,QAAQ,EACR,KAAK,QAAQ,GACd,MAAM,aAAa,CAAA;AAGpB,cAAc,aAAa,CAAA;AAG3B,OAAO,EACL,iBAAiB,EACjB,kBAAkB,EAClB,QAAQ,EACR,eAAe,GAChB,MAAM,iBAAiB,CAAA;AAGxB,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,gBAAgB,EAChB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAA;AAGpB,OAAO,EACL,aAAa,EACb,MAAM,EACN,MAAM,EACN,eAAe,GAChB,MAAM,cAAc,CAAA;AAGrB,OAAO,EACL,MAAM,EACN,SAAS,EACT,aAAa,GACd,MAAM,aAAa,CAAA;AAGpB,OAAO,EACL,QAAQ,EACR,MAAM,EACN,gBAAgB,EAChB,IAAI,EACJ,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,SAAS,EACT,WAAW,EACX,YAAY,EACZ,KAAK,GACN,MAAM,gBAAgB,CAAA;AAGvB,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,cAAc,CAAA;AAGrB,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,WAAW,EACX,YAAY,EACZ,SAAS,EACT,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,sBAAsB,EACtB,QAAQ,EACR,KAAK,gBAAgB,EACrB,KAAK,0BAA0B,EAC/B,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC5B,MAAM,WAAW,CAAA"}
@@ -15,8 +15,8 @@ export { getValidationData, formatSchemaErrors, validate, validateRequest, } fro
15
15
  export { effectBridge, buildContextLayer, getEffectRuntime, } from './bridge.js';
16
16
  // Handler
17
17
  export { effectHandler, effect, handle, errorToResponse, } from './handler.js';
18
- // Action Factories
19
- export { effectAction, dbAction, authAction, simpleAction, injectUser, dbOperation, prepareData, preparedAction, } from './action.js';
18
+ // Action Composables
19
+ export { action, authorize, dbTransaction, } from './action.js';
20
20
  // Response Helpers
21
21
  export { redirect, render, renderWithErrors, json, text, notFound, forbidden, httpError, prefersJson, jsonOrRender, share, } from './responses.js';
22
22
  // Routing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "honertia",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Inertia.js-style server-driven SPA adapter for Hono",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",